Files
2026-08-15 07:13:00 +00:00

6.7 KiB

simplepages

A static-site deployment target for CI. Your build job pushes a directory; the site switches over to it in one step.

The problem it exists to solve is the one rsync has: rsync writes the new build into the live directory file by file, and for the length of that transfer visitors get a half-updated site — new HTML asking for JS that is not there yet, hashed asset names that do not resolve, images 404ing. simplepages closes that window. A deployment is uploaded and verified in full first, and only then does one atomic pointer store make it live.

The guarantee: every request sees the whole old deployment or the whole new one, never a mixture. That includes requests already in flight — a download that started before the switch keeps reading the deployment it started on, all the way to its last byte, even if that deployment is deleted meanwhile.

Rolling back is the same operation in reverse, so it costs one pointer store too: the old deployment's files are still on disk.

How it fits together

CI runner                       pages-server                     visitor
─────────                       ────────────                     ───────
pages deploy ./dist ──manifest──▶ "I am missing 11 of these 142"
                   ──blobs─────▶  content-addressed store
                   ──finalize──▶  assemble tree, fsync, rename
                   ──activate──▶  ┌──────────────────────────┐
                                  │ atomic.Pointer[Deployment]│◀── GET /~demo/
                                  └──────────────────────────┘
                                  $WEBROOT/~demo -> …/dpl_a1b2…

Uploads are content-addressed and incremental: the CLI sends a manifest of paths, SHA-256 digests and sizes, and the server answers with the digests it does not already have. A rebuild that changes one file uploads one file, no matter how big the site is, and identical files are stored once across every deployment and every project.

$WEBROOT/~PROJECT is kept pointing at the live deployment's directory so nginx or any other external consumer can serve it directly, but the server serves the site itself and does not depend on that symlink being correct.

Quick start

make build                        # -> bin/pages-server and bin/pages

export PAGES_DATA=$(mktemp -d) PAGES_WEBROOT=$(mktemp -d)
bin/pages-server --data-dir "$PAGES_DATA" --webroot "$PAGES_WEBROOT" &

On first start the server mints an admin key and writes the token to $DATA_DIR/bootstrap-token, mode 0600. That is the only place it ever writes a token to disk; delete the file once you have taken the token.

export PAGES_SERVER=http://127.0.0.1:8081
export PAGES_TOKEN=$(cat "$PAGES_DATA/bootstrap-token")

bin/pages project create demo
export PAGES_TOKEN=$(bin/pages key create --project demo -o json | jq -r .token)

mkdir -p dist/assets
echo '<h1>v1</h1><script src="assets/app.js"></script>' > dist/index.html
echo 'console.log(1)' > dist/assets/app.js

bin/pages deploy ./dist --project demo
curl -s localhost:8080/~demo/          # <h1>v1</h1>…

Change one file and deploy again — the CLI reports how much the manifest negotiation saved:

142 files, 3.1 MiB; 1 new blob, 402 KiB to upload

Give CI a project-scoped key, never the admin one. A project key can create, upload, finalize, activate and delete deployments in its own project and read that project's settings, and nothing else anywhere.

Two binaries

pages-server The server: management API on --api-listen (default 127.0.0.1:8081), static content on --listen (default :8080). Storage is SQLite plus a content-addressed blob store under --data-dir.
pages The CLI. Pure HTTP client — no SQLite, no cgo, no server packages linked in. It is downloaded by CI runners, so keeping it that way is a feature, and cmd/pages/deps_test.go fails the build if a server dependency creeps in.

CLI

pages deploy <dir>                  upload a directory and switch to it
pages project create|list|show|update|delete
pages deployment list|show|activate|delete    # activate an older one = rollback
pages key create|list|revoke
pages whoami                        which key am I actually using
pages system info|gc|fsck           admin only
pages config show|set|path
pages version

Settings resolve flag → PAGES_* environment variable → config file → built-in default. In CI, pass the token as PAGES_TOKEN or --token-file; --token puts the secret in argv, which on a shared runner is world-readable through /proc/<pid>/cmdline.

Every pages deployment command works with a project key, so a CI job can roll its own project back without an admin credential.

Building and testing

make build       # both binaries, -trimpath -ldflags '-s -w'
make test        # go test ./...
make race        # go test -race ./...
make atomicity   # the switch-is-atomic proof, -race -count=20
make vet fmt tidy

make atomicity is the test that guards the one thing this project sells: 64 readers hammer three files plus a 512 KiB body while a writer activates 50 deployments back to back, and any reader that observes two versions at once fails the run.

Security

Read docs/operations.md §7 before hosting anything you care about. The headlines:

  • Path routing is not a security boundary. /~a/ and /~b/ share one origin, so project A's JavaScript can read project B's files, cookies and localStorage, and can register a service worker that intercepts every project on the host. This is fine for mutually trusting projects, which is what v1 targets. Hosting untrusted sites needs one domain per project.
  • Client-supplied hashes are never trusted: the server rehashes every uploaded byte and rejects a mismatch, which is what makes cross-project dedup safe.
  • Tokens appear once, at creation. They are never logged, never accepted in a query string, and never included in an error message.
  • Blobs and assembled files are 0444, directories 0755; upload permissions are ignored entirely, so a setuid bit cannot reach disk.
  • A project key can tell whether a digest it already knows exists on the server (a blob existence oracle). Known, accepted, and documented in §7.

Documentation

docs/operations.md — installation, configuration, deploying, garbage collection and retention, the security model and its limits, scaling limits, troubleshooting and backups.

AGENTS.md — repository map, the invariants that hold the guarantee up, and what to run before calling a change done.