Runbook: backup restore rehearsal (compose stack)
Quarterly, ~20 minutes. Proves that the daily pg_dump produced by the
backup compose sidecar can actually rebuild the database, and produces the
RTO/RPO numbers for the compliance evidence table. The automated in-CI version
of this drill is go test -tags=integration -run TestDR_BackupWipeRestoreVerify ./tests/integration/dr/ (see docs/runbooks/dr-drill.md); this runbook is
the hands-on doc-test against a live compose deployment.
What produces the backups
The backup service in deploy/docker-compose.prod.yml runs
scripts/dr/backup_compose.sh daily at 02:00 UTC into the backup_data
volume (/data/backups), custom format (pg_dump -F c), gzipped, 7-day
retention.
Step 1 — take a fresh backup
cd deploy
docker compose -f docker-compose.prod.yml exec backup /bin/sh /app/backup.sh
docker compose -f docker-compose.prod.yml exec backup ls -lh /data/backups
# e.g. pg_20261005_021500.dump.gz
Step 2 — copy the dump off the volume
DUMP=pg_20261005_021500.dump.gz # pick the newest
docker compose -f docker-compose.prod.yml cp backup:/data/backups/$DUMP /tmp/$DUMP
ls -lh /tmp/$DUMP # record the size for the evidence table
Step 3 — restore into a disposable Postgres
Never restore into the live pg_data volume. Use a throwaway container:
docker run -d --name restore-drill -e POSTGRES_PASSWORD=drill \
-e POSTGRES_DB=yabanin_drill postgres:16-alpine
sleep 5 # let it initialize
docker cp /tmp/$DUMP restore-drill:/tmp/$DUMP
gunzip -c /tmp/$DUMP > /tmp/$DUMP.raw # custom format: gunzip first
START=$(date +%s)
docker exec restore-drill psql -U postgres -c 'SELECT count(*) FROM information_schema.tables WHERE table_schema='"'"'public'"'"''
# ^ must print 0 — the drill target is empty (same fail-closed check as
# scripts/dr/restore.sh; refuse to continue otherwise)
docker exec restore-drill pg_restore -U postgres --no-owner --no-acl \
--exit-on-error -d yabanin_drill /tmp/$DUMP.raw
echo "restore took $(( $(date +%s) - START ))s" # RTO core component
Step 4 — verify the restored data
docker exec restore-drill psql -U postgres -d yabanin_drill -c \
"SELECT count(*) AS orgs FROM organizations;
SELECT count(*) AS keys FROM api_keys;
SELECT count(*) AS reqs FROM request_logs;"
Expect org/key counts to match the live system (docker compose -f docker-compose.prod.yml exec postgres psql -U yabanin -d yabanin -c "<same queries>"). request_logs lags by the backup age — that delta IS the RPO.
Integrity checks beyond row counts:
-
Audit hash chain: the production verifier runs in the automated drill (
pkg/auditlog/verifier, step 6 ofdocs/runbooks/dr-drill.md); for the manual rehearsal, spot-check that the newestadmin_audit_logssequences are contiguous:docker exec restore-drill psql -U postgres -d yabanin_drill -c \"SELECT max(seq), count(DISTINCT seq) FROM admin_audit_logs;"(
max(seq) == count(DISTINCT seq)means no gaps; chain-hash verification stays with the automated drill.)
Step 5 — record evidence
Append to the team DR log:
| Date | Dump size | Restore time | RPO (backup age) | Issues | Sign-off |
|---|---|---|---|---|---|
| 2026-Q4 |
Targets (from docs/runbooks/dr-drill.md): RTO < 60 min end-to-end, RPO < 24 h
for the daily sidecar (tighten by adding host-cron scripts/dr/backup_all.sh
runs if needed).
Step 6 — cleanup
docker rm -f restore-drill
rm -f /tmp/$DUMP /tmp/$DUMP.raw
The dump contains customer data — delete it from the rehearsal host.
If the restore fails
--exit-on-errorstops at the first bad item — read the first error, not the last.- Common cause: dump from a newer migration set restored into an older
Postgres — use the same major version (
postgres:16-alpine). - If backups have been failing silently, check the sidecar log:
docker compose -f docker-compose.prod.yml logs backup(cron output goes to the container log; the script is fail-fast viaset -euo pipefail). - A real incident restore follows the same commands against the (recreated)
live volume — fail-closed wrapper for host-side runs:
scripts/dr/restore_all.sh --backup-ts <TS>.