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 andlocalStorage, 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, directories0755; 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.