Operations
Everything here is a summary. The runbooks in the Hill90 repository are the procedures; they change in the same pull request as the code they describe, and CI checks them.Deploying
Five units, deployed independently:docker-compose.infra.yml creates the
three Docker networks everything else attaches to as external. Vault deploys before
anything meant to read secrets from it, and deploy.sh vault auto-unseals after compose
up. db deploys before auth — the script refuses an auth deploy it cannot first
query Postgres for, rather than leaving Keycloak crash-looping against a missing
database. Observability has no dependencies.
Compose project names are per stack (hill90-{env}-{stack}), so an errant
docker compose down cannot reach across stacks. There are four projects; the vault and
the database share platform, while identity has its own.
Deploy safety
The edge stack is excluded from push-triggered deploys on purpose: recreating Traefik
re-requests certificates, and Let’s Encrypt rate limits make that something to do
deliberately. See Certificates.
Backups
Backups happen on two schedules, both verified on the host:- Before every stateful deploy —
deploy.shrunsscripts/backup.shahead of the down/up cycle forvault,db,authandobservability. - Nightly at 03:00 — a cron entry runs
backup.sh backup-all, with a weeklybackup.sh prune 7at 04:00 on Sundays. The prune genuinely runs; the log records✓ Pruned 14 backup(s), and the oldest surviving directories are six days old.
The database backups take both a volume tar and a logical
pg_dumpall. A dump that
cannot be produced is now a hard failure, and artifacts are checked non-empty before a
run reports success.
The app-db target is the one deliberate exception, added 2026-07-31. Its container no
longer exists and its data is covered by the db target, so it reports the retirement,
archives the retained volume for as long as that volume is there, and succeeds. Making it
fail instead would have turned the nightly job red for data that is backed up, and a
backup job that cries wolf is how a real failure gets ignored.
That changed on 2026-07-29, and the reason is worth recording. Under cron,
PATH=/sbin:/bin:/usr/sbin:/usr/bin while sops installs to /usr/local/bin, so the
secrets decrypt was command not found; its stderr was discarded, the database user came
back empty, the SQL dump was skipped with a warning, the volume tar still ran, and the job
exited 0. Three consecutive nightly backups held a tar and no dump. The same command
worked by hand, which is why it went unnoticed.
The tenant’s volume had also never been backed up by anything. Both are fixed, and the
restore is proven — a dump was restored into a throwaway container on 2026-07-29 and
the recovered accounts carried their correct roles.
Not covered by any backup: object storage, agent state, and the agentbox configuration
directory, which is a host path outside every checkout.
Rebuilding the VPS
The rest of this section is summarised from the Hill90 runbooks and has not been
independently verified here — that the rebuild needs no manual intervention, that
bootstrap takes three to five minutes, that Tailscale auth keys are generated and rotated
during the rebuild, and that DNS is reconciled as part of it. Verifying them means running
a rebuild.Treat them as the procedure’s stated intent. One claim of exactly this kind — that the
bootstrap disables SSH password authentication — turned out to be false on the running
host (issue 539), so the runbooks are a
starting point rather than an authority about the machine’s current state.
Disaster recovery
Recovery from total VPS loss depends on artefacts that must exist before the loss:- A local clone of the repository, current with
main - The encrypted secrets store,
infra/secrets/prod.enc.env - The age private key — gitignored and never committed; restore it from your password manager or the VPS. Its filename and location are in the private repository, not here: it is the one artefact that decrypts every other secret
- Hostinger API access