ops: deploy the server from GHCR, and a verified restore drill - #22
Merged
Merged
Conversation
Production built its own image from a git checkout living inside a Docker volume. That tree drifted from every other copy of the repo five times (dirty trees, a commit existing nowhere else, a stale feature branch that would have reverted a prod fix), each build took 13-17 minutes competing with the live server for the host's two cores, and its .git/config carries a GitHub PAT in plaintext. CI already publishes multi-arch images to GHCR on every push to main, so compose.prod.yml now pulls a pinned sha-<short> tag instead. SERVER_TAG is required, with no default, because latest is mutable and cannot be rolled back to. The deployed commit is now whatever tag is recorded in the environment rather than whatever happens to be on disk in a volume. Also adds the restart policy the server was missing: it was the only container in the stack set to "no", so a host reboot left the API down until someone noticed. The restore runbook records a drill actually run against the R2 archive on 2026-09-21: 6s to fetch the base, 68s to replay WAL, recovery point five seconds behind the moment it started, and a table set identical to the independent nightly pg_dump. It also records the two ways a drill can damage production - archiving back into the shared prefix, and answering to the name voltius-db - which is why those steps are explicit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Preparation for growth on the single-box setup. Nothing here is applied to production — the compose change takes effect only when the container is next recreated, and the drill it documents has already been run.
Deploy from GHCR instead of building on the host
compose.prod.ymlbuilt the image on the production host from a git checkout living inside a Docker volume. That tree drifted from every other copy of the repo five separate times: dirty trees at ancient HEADs, a commit that existed in no other repo, and a stale feature branch whose merge would have reverted a production fix. Each build takes 13–17 minutes competing with the live server for this host's two cores, and that checkout's.git/configholds a plaintext GitHub PAT..github/workflows/docker.ymlalready publishes a multi-arch image on every push tomain, so production now pulls it:SERVER_TAGhas no default on purpose.latestis mutable, overlapping pushes race on it, and it cannot be rolled back to. With no tag set,up -drefuses to start rather than deploying something unidentified.Also adds
restart: unless-stopped, which this service was missing. It was the only container in the stack with a restart policy ofno, so a host reboot left the API down until someone noticed.Verified before opening this:
ghcr.io/voltiusapp/voltius-server:sha-469337cexists with bothlinux/arm64andlinux/amd64, and the deployment tree's tracked files are clean at469337c, which isorigin/main. The cutover is therefore content-neutral — the same code, from a different source.Runbooks
docs/runbooks/deploy-server.mdcovers the pinned-tag deploy, rollback, and the first cutover from the local-build setup. It records the traps that have each cost time before:--env-fileis mandatory or the server starts with blank secrets and fails at the first authenticated request; an unauthenticated request to a route returns 401 if it exists and 404 if the image lacks it, which proves route registration without writing production data; and a rollback reverts the binary but never a migration.docs/runbooks/restore-database.mddocuments a restore drill actually run against the R2 archive on 2026-09-21 — the first time that archive had ever been restored:pg_dumpIt was run twice, the second time by executing the runbook's own commands verbatim, so the document is tested rather than written from memory.
Two ways a drill can damage production are called out explicitly, because both are silent: a restored copy promoted with
archive_mode=onpushes its timeline-2 WAL into the shared R2 prefix that production depends on, and a drill container attached to thecloudflarenetwork answers to the namevoltius-db.One incidental finding: the restored database is 54 MB, of which
sync_blobsis 44 MB. That accounts for the nightly dump growing 15 MB to 32 MB over five weeks — payload, not an unpruned metadata table.Applying it
SERVER_TAG=sha-<short>into.env.dockhand.compose.prod.ymlinto the deployment tree.pull, thenup -d server, then check health and the 401.