Skip to content

ops: deploy the server from GHCR, and a verified restore drill - #22

Merged
kipavy merged 1 commit into
mainfrom
ops/ghcr-deploy-and-restore-runbooks
Sep 21, 2026
Merged

kipavy merged 1 commit into
mainfrom
ops/ghcr-deploy-and-restore-runbooks

Conversation

@kipavy

@kipavy kipavy commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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.yml built 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/config holds a plaintext GitHub PAT.

.github/workflows/docker.yml already publishes a multi-arch image on every push to main, so production now pulls it:

image: ${SERVER_IMAGE:-ghcr.io/voltiusapp/voltius-server}:${SERVER_TAG:?pin an immutable sha-<short> tag, never latest}

SERVER_TAG has no default on purpose. latest is mutable, overlapping pushes race on it, and it cannot be rolled back to. With no tag set, up -d refuses 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 of no, so a host reboot left the API down until someone noticed.

Verified before opening this: ghcr.io/voltiusapp/voltius-server:sha-469337c exists with both linux/arm64 and linux/amd64, and the deployment tree's tracked files are clean at 469337c, which is origin/main. The cutover is therefore content-neutral — the same code, from a different source.

Runbooks

docs/runbooks/deploy-server.md covers 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-file is 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.md documents a restore drill actually run against the R2 archive on 2026-09-21 — the first time that archive had ever been restored:

Measure Result
Base fetch 6 s, 77 MB
WAL replay to end of archive 68 s
Total to accepting connections ~95 s
Recovery point last transaction ~5 s before the drill began
Schema 32 tables, migration 42, 0 failed
Cross-check table set identical to the independent nightly pg_dump

It 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=on pushes its timeline-2 WAL into the shared R2 prefix that production depends on, and a drill container attached to the cloudflare network answers to the name voltius-db.

One incidental finding: the restored database is 54 MB, of which sync_blobs is 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

  1. SERVER_TAG=sha-<short> into .env.dockhand.
  2. Tag the last locally built image as a fallback.
  3. Copy this compose.prod.yml into the deployment tree.
  4. pull, then up -d server, then check health and the 401.

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.
@kipavy
kipavy merged commit bfd0f9a into main Sep 21, 2026
2 checks passed
@kipavy
kipavy deleted the ops/ghcr-deploy-and-restore-runbooks branch September 21, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant