Server access and break glass
The Deploy workflow and the ops workflows run a product without a shell on its server (Operations). This page is for the times they cannot: a way in prepared before anything breaks, the failures that need it, and a second product on the same box.
One product per server
Section titled “One product per server”Give each product a server of its own. Two products on one box share more than its CPU:
- Ports. One Caddy owns 80 and 443 and terminates TLS for every host on the box, and each stack
publishes its Postgres on a loopback port (
POSTGRES_PORT). - One lock and one state directory per Linux user. Deploys, deployment recovery, the ops
workflows that change something and the demo’s reset take one host lock, and the recovery journal
lives in one directory (
~/.local/state/starterdough). Two products under one user queue behind each other, and an interrupted deploy of one blocks every deploy of the other until it is recovered. - A noisy neighbour. A migration, a backup or a burst of traffic on one takes memory, CPU and disk from the other. Compose caps the app containers, not Postgres.
- One
dockergroup. A deploy user can start any container, which on that box is as good as root, so each product’s deploy key reaches every other product’s data.
A second small VPS costs less than the hours a shared box takes. The public demo is the one case
the kit itself puts next to production (infra/README.md, “A public demo”): its data is
disposable, and it shares production’s user, lock and Caddy on purpose.
Break glass, prepared at provisioning
Section titled “Break glass, prepared at provisioning”CI’s deploy key is not your way in. It lives in a secret of the production environment, and you
revoke it the moment it leaks. Prepare a way in of your own before you need one:
-
A hardware-backed key of your own, made on a security key and added to root’s
authorized_keysbefore you runprovision.sh, which gives the deploy user root’s keys when it has none yet:Terminal window ssh-keygen -t ed25519-sk -O resident -O verify-required -C break-glassresidentkeeps the key on the token, sossh-keygen -Kloads it on a new laptop, andverify-requiredasks for the token’s PIN as well as a touch. Register a second token the same way and keep it somewhere else.provision.shends with this advice when root has nosk-key. -
The provider’s rescue console: its browser console (VNC or serial) and its rescue system. Try both once while nothing is wrong. They are the way in when SSH is down, the firewall shuts you out or the disk is full.
-
The root password that console asks for, in your password manager.
-
One deploy key pair per product, made for CI alone and authorized only for that product’s deploy user. Never put your personal key in
DEPLOY_SSH_KEY, and never one key in two repositories. -
Rotation without a gap. Append the new public half to
/home/deploy/.ssh/authorized_keys, replace theDEPLOY_SSH_KEYsecret, run Server status to prove the new key works, then delete the old line. After a leak, delete the old line first. -
A pinned host key.
DEPLOY_KNOWN_HOSTSholds the server’s SSH host key, and every workflow refuses a server that presents another one (Host key verification failed). A rebuilt box has a new key: comparessh-keyscan -t ed25519 <host> | ssh-keygen -lf -withssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubrun in the provider’s console, and once they match, set the variable to the linessh-keyscan -t ed25519 <host>prints. Keep one server’s key in it: every key in the variable is trusted. Tailscale SSH (tailscale up --ssh) answers with a host key of its own, andprovision.shdoes not turn it on.
When a deploy fails
Section titled “When a deploy fails”Read the Deploy run’s log first, then run Server status for production: it shows the containers and whether a deployment journal is pending.
- It failed before anything stopped: the pull, a pre-deploy hook or the release backup.
Production still runs the previous release and no journal exists. Fix the cause and run Deploy
again. A release backup that gave up on the backup lock met a nightly backup that ran for more
than 10 minutes: run Deploy again. With
DEMO_PATHset, the demo has already moved to the new build. - It was interrupted: the run was cancelled or timed out, SSH dropped, the box rebooted. Run
Recover deployment with
status, thenrecover. Before the forward-only phase it restarts the previous release’s containers; once that phase is recorded, it finishes starting the new release instead. - A migration fails every time.
recoverwould only run it again, and the journal blocks every deploy, the fix included. Run Recover deployment withabandonand, inconfirm, the first 7 characters of the revisionstatusprinted. It drops the journal and starts nothing. Then deploy the fix, or the previous tag. - A migration ran and damaged data. Restore the release set the deploy took just before it stopped the writers, which takes a shell on the box (below).
As the deploy user, in the checkout: stop the writers, find the set taken just before the deploy
that ran the migration (a later deploy’s set already holds the damage), restore it, and drop the
journal if status shows one:
docker compose stop api workerdocker compose run --rm backup bun src/cli.ts list --releasedocker compose run --rm backup bun src/cli.ts restore <stamp> --release --yesbash infra/scripts/recover-deployment.sh abandon <first 7 characters of the revision>Then deploy the previous tag (Actions → Deploy → Run workflow, with tag), whose code matches the
restored schema. Whatever was written after the release backup is lost, the new release’s traffic
included. The restore writes a dump of the database it replaces first, so it can itself be undone.
It leaves the database holding exactly what the set holds: the tables, types and functions the
migration created go too, so a release that runs that migration again applies it from the start.
A journal that a 1.0.0 deploy wrote is recovered by the 1.0.0 scripts in that checkout, which have
recover but no abandon. Dropping one takes a shell: check that
bash infra/scripts/recover-deployment.sh status prints deployment_recovery_phase=forward-only,
then delete ~/.local/state/starterdough/deployment-interrupted, which is all abandon does.
Sharing a box anyway
Section titled “Sharing a box anyway”When a second product has to live on the same server, give it its own copy of everything the first section lists. On the box, as root, and in the second product’s repository:
-
Its own Linux user, so its own home, state directory and host lock. Run
provision.shagain with aDEPLOY_USER, aDEPLOY_PATHand aREPO_URLof its own, andEXPOSE=tailscale. It leaves Docker, the firewall and the first checkout as they are, and writes the second checkout’s.env. -
Its own checkout,
DEPLOY_PATHand deploy key, in its repository’s variables and secrets.DEPLOY_KNOWN_HOSTSis the same host key in both. -
A Compose project of its own.
bun run renamesets thename:ininfra/compose.ymlto the product’s slug, which keeps its containers and volumes apart from the first product’s. Two products that kept the kit’s name would both bestarterdoughand share volumes, and a deploy’s project check could not tell them apart, so rename before you share a box. -
Free ports, in its
.env:POSTGRES_PORT=5434, with the same port inDATABASE_URL, andCADDY_HTTP_PORTandCADDY_HTTPS_PORTthat nothing else on the box uses, such as8081and8444.EXPOSE=tailscalehas already setCADDY_MODE=single-originandCADDY_BIND=127.0.0.1: its Caddy serves plain HTTP on one origin, the app at/, the API under/apiand/rpc, the docs under/docs, and no marketing site. Set itsWEB_URLandAPI_URLto that origin,https://acme.example.com, andDOCS_URLto the same with/docs. -
The first product’s Caddy in front of it. Both join one Docker network: set
PROXY_NETWORK=box-proxyin both.envfiles, rundocker network create box-proxy, and addinfra/compose.proxy-network.ymlto the first product’sCOMPOSE_FILE. The second product joins under a name no other container on that network has, with an overlay of its own, sayinfra/compose.box.yml, added to itsCOMPOSE_FILE(COMPOSE_FILE=infra/compose.yml:infra/compose.box.yml):services:caddy:networks:default:proxy:aliases: [acme-caddy]postgres:deploy:resources:limits:memory: 1gnetworks:proxy:external: truename: ${PROXY_NETWORK:?set PROXY_NETWORK in .env}The first product’s Caddy then proxies the domain to it from a drop-in,
infra/caddy/conf.d/acme.caddyin the first checkout, which you validate and reload asinfra/caddy/conf.d/README.mdsays:acme.example.com {import securityimport logsreverse_proxy acme-caddy:80 {import upstreamimport resilienthealth_uri /healthz}} -
A memory cap on both Postgres containers,
deploy.resources.limits.memoryas above. The first product needs an overlay of its own for it, with only thepostgrespart. Size both caps so that the two databases, the two stacks and the page cache fit the box. -
A bucket prefix of its own: a distinct
BACKUP_S3_PREFIXwhen both back up to one bucket. Uploads need the same care: a bucket, or a prefix, per product.