feat(ops): scheduled Postgres backups to restic/S3 with a tested restore - #93
Merged
Conversation
tibroc
commented
Aug 28, 2026
tibroc
left a comment
Member
Author
There was a problem hiding this comment.
Supervisor review — looks good; merge is deliberately left to @timb for manual review.
Highlights verified: fail-closed script-level env validation (with the correct compose interpolation-vs-profiles rationale), the bounded repo probe with per-failure-class messages incl. refusing to init on a wrong password, empty-dump guard, tag-scoped retention, egress network separated from edge (X-Forwarded-* trust), cap_drop+no-new-privileges, and the MinIO destroy-and-restore rehearsal.
Two advisory follow-ups, non-blocking:
- The dump + restic cache live in an unsized tmpfs (/tmp) — fine today, but click_events/usage growth makes an explicit size (e.g. tmpfs: /tmp:size=512m) or a small named volume worth a line.
- No periodic
restic check— consider every Nth cycle or a documented quarterly operator task, since snapshots are otherwise only proven readable at restore time.
The pre-existing SELinux mount finding is now #94 (M4).
Closes the last M4 gap from docs/04 §5: there was no backup automation anywhere in the repo, so data durability rested on the pgdata volume never being lost. Adds an opt-in `backup` service to compose.prod.yaml (profile `backup`) that loops pg_dump -Fc → restic backup → restic forget --prune against an S3-backed restic repository. Deliberately boring: one pinned image (postgres:17-alpine, so pg_dump always matches the server's major version) running a POSIX shell loop; restic comes from the matching Alpine community repo at start-up. No cron daemon, no scheduler, no second published image. - Optional by construction: without --profile backup the service does not exist and the stack runs exactly as before. Its variables use `:-` rather than compose's `:?` fail-closed form, because compose interpolates the file before applying profiles — the fail-closed check lives in backup.sh instead. - Never fails quietly: missing config, an unreachable bucket, rejected credentials, a wrong repository password, an unreachable Postgres and an empty dump each log a distinct ERROR and exit non-zero, so the container crash-loops visibly. `backup cycle ok` is logged only when pg_dump, restic backup and restic forget all succeeded. - Nothing institution-specific is committed: repository URL, password, S3 credentials, schedule and retention are all env, documented with defaults in .env.example, config.example.yaml and README → Backups. - Joins `backend` (for Postgres) plus a new `egress` network for S3 — deliberately not `edge`, which is the subnet the app trusts for X-Forwarded-* headers. docs/runbooks/restore-postgres.md is rewritten around the restic path (list snapshots → restic restore → pg_restore, all inside the backup container, which already has the right pg_restore major version and the credentials), keeping the bare-dump procedure as a fallback section. The stale "there is no backup job in this repo" banner is gone. Rehearsed end to end against MinIO: real backup taken, pgdata volume destroyed, restored following the runbook verbatim, row counts and marker rows identical, /readyz ready. Evidence in the PR body.
tibroc
force-pushed
the
feat/restic-postgres-backups
branch
from
September 3, 2026 12:52
c81b855 to
c3cbacd
Compare
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.
Closes #89.
docs/04§5 asks for "regular Postgres backups + a tested restore"; there was no backup automation anywhere in the repo, so durability rested entirely on thepgdatavolume never being lost. This adds the org's standard pattern —pg_dump→ restic → S3, with arestic forget --pruneretention policy — and rewrites the restore runbook around it.What's here
A new opt-in
backupservice incompose.prod.yaml(profilebackup) runningdeploy/backup/backup.sh:Deliberately boring: one pinned image (
docker.io/library/postgres:17-alpine) and a POSIX shell loop. No cron daemon, no scheduler, no second published image. Thepostgres:*-alpinebase is the load-bearing choice —pg_dumpthen always matches the server's major version, which an older client refuses to do.resticisapk added at container start rather than baked into a separately built image; see Trade-offs below.Optional, for real
Without
--profile backupthe service does not exist:Note the second line: with the profile on but nothing configured, compose still parses. That's on purpose — compose interpolates the whole file before it applies profiles, so the
${VAR:?…}fail-closed form used forSESSION_SECRETand friends would abortcompose upfor every deployment that never enables backups. The backup variables therefore use:-, and the fail-closed check moved intobackup.sh, which refuses to start and says why.It never fails quietly
Every failure path logs a distinct
ERRORand exits non-zero, so the container crash-loops underrestart: unless-stoppedinstead of silently skipping backups.backup cycle okis logged only whenpg_dump,restic backupandrestic forget --pruneall succeeded. All six paths were exercised (transcripts below).Nothing institution-specific
RESTIC_REPOSITORY,RESTIC_PASSWORD,AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION,BACKUP_INTERVAL_SECONDS(86400),BACKUP_RUN_ON_START(true),BACKUP_KEEP_DAILY/WEEKLY/MONTHLY(7/4/6),BACKUP_TAG(wolke-db),BACKUP_INIT_REPO(true),BACKUP_PROBE_TIMEOUT(45),BACKUP_RESTIC_VERSION— all env, documented with defaults in.env.example, pointed at fromconfig.example.yaml, and tabulated in README → Backups.Networking
backend(to reach Postgres) plus a newegressnetwork for the S3 endpoint. Deliberately notedge— that's the subnet the app trusts forX-Forwarded-*headers, and a backup job has no business being in it. Postgres stays internal-only.Runbook
docs/runbooks/restore-postgres.mdis rewritten around list snapshots →restic restore→pg_restore, all inside the backup container (it already has the rightpg_restoremajor version, the repository credentials, and network reach to both S3 and Postgres). The bare-dump procedure is kept as Fallback: restore from a bare dump file. The stale "there is no automated backup job in this repo" banner is gone, anddocs/04§5 now records what shipped.Verification
Stood the production stack up locally with MinIO as the S3 bucket, took a real backup, destroyed the
pgdatavolume, and restored following the runbook.Honest caveats about the rehearsal, up front:
podman-compose1.x + podman 5.8.4, notdocker compose(this workstation has no docker). Same flags.compose.prod.yamldiffering in exactly one line —edge's subnet moved from172.28.0.0/16to172.31.0.0/16, because another project on this machine already owns 172.28/16.diffconfirmed that was the only difference. Thebackupservice, theegressnetwork and every backup path are byte-identical to what's committed.:ro,zadded locally for SELinux (rootless podman on Fedora). Pre-existing:compose.prod.yamlalready carries:ro,zon theCaddyfilemount but not on./config.yaml. Not changed here — flagging it as a separate small papercut for podman/SELinux deployers./readyzbe reachable without Caddy.1. A real backup
State at backup time (seeded catalog + a marker user with a favorite):
2. Destroy the database volume
3. Restore, following the runbook verbatim
Step 2 of the runbook — list snapshots:
Step 3 — restore +
pg_restore, copy-pasted from the runbook:Step 4 — start the app:
4. Verify the restore actually worked
Row counts — identical to pre-destruction:
Marker row survived,
is_adminintact:Catalog contents:
Runbook step 6 — backup service resumed, next cycle clean:
5. Every failure mode, exercised
ERROR missing required environment: RESTIC_REPOSITORY RESTIC_PASSWORD PGHOST PGUSER PGPASSWORD PGDATABASE/the backup service is enabled but not configured — refusing to startERROR cannot reach the backup repository: …/check RESTIC_REPOSITORY and that the S3 endpoint is reachable from the egress networkERROR the backup repository rejected our credentials: …Stat: The Access Key Id you provided does not exist in our records./check AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGIONRESTIC_PASSWORDERROR RESTIC_PASSWORD does not open this repository/refusing to continue — a wrong password must never silently start a second repositoryERROR pg_dump failed — is postgres reachable and the password correct?ERROR could not install restic (no network to the Alpine mirrors?)All six then log
backup cycle FAILED — no snapshot was written; exiting so the restart policy surfaces thisand exit 1.One finding worth calling out: restic retries backend errors with exponential backoff for ~15 minutes before giving up, so a typo'd bucket name would have sat silent for a quarter of an hour. The repository probe is therefore wrapped in
timeout ${BACKUP_PROBE_TIMEOUT:-45}, and the failure classes are told apart by pattern so each gets an actionable message.shellcheck -s sh deploy/backup/backup.sh(v0.10.0): clean, no findings.Trade-offs worth a reviewer's opinion
apk add resticat container start, rather than a purpose-built image. The alternative is aDockerfile.backup(postgres:17-alpine+COPY --from=restic/restic) published by CI to a second ghcr package. That would keepread_only: trueand drop the runtime network dependency, at the cost of a new workflow, a second image to version, and an operator requirement to have built it. The chosen path keeps the moving parts in the repo to one shell script and one pinned upstream image, and the failure is loud and at start-up. Two consequences a reviewer should weigh:read_only: true(apk writes across/usr,/lib,/etc).no-new-privilegesandcap_drop: ALLare kept.BACKUP_RESTIC_VERSIONpins the package if reproducibility matters.Happy to switch to a CI-built image if you'd rather have the hardening back.
postgres:17-alpineis pinned to the major, not a patch tag — matching howpostgres:17is pinned for the server in the same file. Pinning the dumper tighter than the server would create a mismatch to maintain.Auto-init defaults to on. Convenient, but a typo'd
RESTIC_REPOSITORYwould silently start a second empty repository rather than erroring.BACKUP_INIT_REPO=falseopts out; happy to flip the default if you'd rather it be explicit.Not wired into
compose.yaml(the staging/build-from-source stack) — the issue and docs/04 scope this to production. Easy to add if you want staging backed up too.Left unmerged for supervisor review.