BimZone — Disaster Recovery Runbook (WS-SEC-BACKUP-DR)¶
This runbook is the operational + audit (SOC 2 A1.2 / A1.3) evidence that BimZone's PostgreSQL data is backed up, recoverable to a point in time, and that the recovery procedure is tested. It states the recovery objectives, the exact recovery commands, and how to run the monthly verification drill.
Scope. This covers the primary datastore (PostgreSQL). Object storage (uploads, fragments, point clouds) durability/versioning/cross-region replication is owned by
docs/storage-r2-s3.md(WS-SEC-S3-R2-VALIDATION); enable bucket versioning + cross-region replication there. Postgres holds all relational/transactional state and is the focus here.
1. Recovery objectives¶
| Objective | Target | How it is met |
|---|---|---|
| RTO (Recovery Time Objective) | ≤ 1 hour | Restore latest base backup + replay WAL (scripts/restore-postgres.sh); or near-zero failover by promoting the streaming standby / managed read replica. |
| RPO (Recovery Point Objective) | ≤ 5 minutes (≈ 1 min in practice) | Continuous WAL archiving with archive_timeout = 60s, so at most ~1 minute of writes is unarchived. A connected streaming standby reduces this to near-zero (synchronous-capable). |
| PITR window | 7 days | 7 days of base backups + all WAL since the oldest retained base backup are kept (see ops/postgres/postgresql.conf retention notes and BACKUP_RETENTION_DAYS=7). |
These targets assume the off-region archive target and (for the near-zero RTO path) a streaming standby or managed read replica are provisioned — see §6.
2. Backup architecture¶
┌─────────────────────────────┐
│ PRIMARY Postgres │
│ wal_level=replica │
│ archive_mode=on │
└─────┬───────────────┬───────┘
│ archive_command│ streaming
│ (every WAL seg)│ replication
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ WAL ARCHIVE │ │ STREAMING STANDBY │ (warm standby / managed read replica)
│ (off-region obj │ │ near-zero-RPO │
│ storage / dir) │ └──────────────────┘
└────────┬─────────┘
│ + nightly pg_basebackup (physical) + pg_dump (logical)
▼
┌──────────────────────────────────────────┐
│ BACKUP_ROOT/ │
│ base/<TS>/ ← physical base backups │
│ dumps/<db>-<TS>.dump ← logical dumps │
│ wal/ ← archived WAL segments │
│ .last-backup ← freshness marker │
│ .last-verify ← tested-backup marker │
└──────────────────────────────────────────┘
- Physical base backup + WAL = the PITR anchor (restore to any instant in the window).
- Logical
pg_dump= portability, single-table recovery, cross-version migration. - Freshness marker (
.last-backup) is surfaced byGET /api/admin/backup-statusso monitoring alarms if backups stall (stale = older than 26h).
3. Taking a backup¶
Run nightly (cron / systemd timer / CI schedule) on a host that can reach the primary:
PGHOST=primary.db.internal PGPORT=5432 PGUSER=bimzone PGPASSWORD=… PGDATABASE=bimzone \
BACKUP_ROOT=/var/backups/bimzone \
BACKUP_RETENTION_DAYS=7 \
OFFSITE_SYNC_CMD='aws s3 sync /var/backups/bimzone s3://bimzone-dr/bimzone/ --storage-class STANDARD_IA' \
scripts/backup-postgres.sh
This produces a physical base backup and a logical dump, prunes artifacts older than
7 days, writes the freshness marker, and (if OFFSITE_SYNC_CMD is set) copies everything
off-region. WAL is archived continuously by Postgres itself via archive_command.
Confirm the marker is fresh:
cat /var/backups/bimzone/.last-backup # ISO-8601 timestamp
curl -s -H "Authorization: Bearer <token>" https://bim.example.com/api/admin/backup-status
# → { "lastBackupAt": "...", "ageSeconds": 1234, "stale": false }
4. Recovery procedures¶
4a. Point-in-time recovery (PITR) — corruption / bad migration / accidental delete¶
Restore the latest base backup and replay WAL up to just before the bad event.
- Stop writes to the damaged primary (or fail traffic over to the standby first — §4b).
- Dry-run the restore to review every action without touching disk:
BACKUP_ROOT=/var/backups/bimzone \
scripts/restore-postgres.sh \
--target-time "2026-06-21 14:29:00+00" \
--data-dir /var/lib/postgresql/restore \
--wal-archive /var/backups/bimzone/wal \
--dry-run
- Execute the restore (drop
--dry-run). It unpacks the newest base backup into the target data dir and writesrecovery.signal+restore_command+recovery_target_time. - Start Postgres against the restored data dir; it replays WAL to the target time and promotes itself:
pg_ctl -D /var/lib/postgresql/restore -l /var/lib/postgresql/restore/recovery.log start
psql -d postgres -c 'select pg_is_in_recovery();' # f → promotion complete
- Repoint the app
DATABASE_URLat the recovered instance and resume traffic.
Pick the base backup explicitly with
--base-dir /var/backups/bimzone/base/<TS>if you need an older anchor; omit--target-timeto replay all available WAL (latest state).
4b. Failover to the streaming standby — primary host/disk loss¶
If a streaming standby (or managed read replica) is running, this is the fast path (near-zero RTO/RPO):
- Promote the standby:
pg_ctl promote -D <standby-data-dir>(or the managed-Postgres "promote read replica" action). - Repoint the app
DATABASE_URLat the promoted node. - Rebuild a fresh standby from a new base backup once the incident is over.
4c. Logical restore — single table / cross-version / portability¶
createdb bimzone_restore
pg_restore --no-owner --no-privileges --dbname=bimzone_restore \
/var/backups/bimzone/dumps/bimzone-<TS>.dump
# add --table=<name> to recover a single table
4d. Full volume-loss drill (proves the whole chain)¶
docker compose stop postgres && docker volume rm bim-zone_pgdata(destroys the data).- Recreate the volume and run
scripts/restore-postgres.shagainst the base+WAL. - Start Postgres, then compare row counts to the pre-loss snapshot (see §5).
5. Monthly verification drill (the "backups are tested" evidence)¶
Run on the first of each month (and after any change to the backup pipeline):
PGHOST=… PGUSER=bimzone PGPASSWORD=… \
BACKUP_ROOT=/var/backups/bimzone \
scripts/verify-backup.sh
verify-backup.sh:
1. picks the newest logical dump,
2. createdbs a throwaway database and pg_restores into it,
3. runs a row-count / structural-checksum sanity check across all user tables,
4. drops the throwaway DB and writes a pass/fail marker to BACKUP_ROOT/.last-verify:
2026-06-21T03:14:00Z PASS tables=64 rows~1284322 schema_md5=ab12…
Evidence to retain for audit:
- the .last-verify marker line (PASS + table/row/checksum figures),
- the script's stdout/stderr log from the scheduled run,
- once a quarter, a full PITR drill (§4a) restoring to an arbitrary in-window timestamp
and confirming row counts match a known snapshot,
- pg_stat_replication output showing a connected standby (when the replica is provisioned).
6. Production / managed-Postgres equivalents (infra-blocked parts)¶
Everything above runs end-to-end against the local docker-compose Postgres. The following require the real production environment and are the only parts not shippable today:
| Local (this repo) | Production target |
|---|---|
archive_command → local wal-archive/ dir |
Off-region object storage (aws s3 cp / rclone to R2 / wal-g wal-push) in a separate region/account. |
| Streaming standby compose service | A managed read replica (RDS/Aurora/Cloud SQL/Crunchy/Neon) or a second host with primary_conninfo + standby.signal. |
OFFSITE_SYNC_CMD copy of BACKUP_ROOT |
Cross-region bucket replication + object-storage versioning (see docs/storage-r2-s3.md). |
On a managed Postgres, wal_level/archive_mode/archive_timeout are set via the provider's
parameter group and PITR is a built-in feature (point-in-time restore to a new instance); the
RTO/RPO targets and the monthly verify-backup.sh drill still apply unchanged.
7. Quick reference¶
| What | Command / file |
|---|---|
| Take a backup | scripts/backup-postgres.sh |
| Restore to a point in time | scripts/restore-postgres.sh --target-time '<ts>' --data-dir <dir> |
| Dry-run a restore | add --dry-run |
| Test a backup (monthly drill) | scripts/verify-backup.sh |
| WAL/PITR settings | ops/postgres/postgresql.conf |
| Backup freshness (monitoring) | GET /api/admin/backup-status → { lastBackupAt, ageSeconds, stale } |
| Freshness marker | BACKUP_ROOT/.last-backup |
| Tested-backup marker | BACKUP_ROOT/.last-verify |