SOP: Postgres Restore (CNPG + MinIO)
SOP: Postgres Restore from CNPG Backup
Replaces:
sop-litestream-restore (archived)Last tested: 2026-04-21 — full drill + PITR PASS (see
validation-postgres-restore-2026-04-21)Expected timing: cluster-apply → first-query-success is ~55 seconds for current paledocs+twitch2kwager-scale data (~200MB base backup) on k3s+local-path storage. PITR adds ~5s for WAL replay. Scales roughly linearly with base-backup size.
Prerequisites
- CNPG operator running (namespace
cnpg-system, deploymentcnpg-cloudnative-pg) - MinIO accessible at
http://minio.minio.svc.cluster.local:9000 cnpg-s3-credssecret inpostgresnamespace (must be copied to the restore namespace — see Step 1.5)- WAL + base backups in
postgres-walMinIO bucket - Image tag for restore cluster:
ghcr.io/cloudnative-pg/postgresql:17— NOT:17.4-1. See Gotcha #1. - NetworkPolicy whitelist:
default-deny-ingressinminions only allows 7 namespaces (tailscale, postgres, woodpecker, monitoring, tofu-state, pal-e-mail, westside-contracts). If restoring into a scratch ns (e.g.postgres-restore-test), patch the netpol before the cluster bootstraps; revert after cleanup. One-liner at bottom of this SOP.
Step 0: Pre-flight Checks
Gate the drill on all five. Any failure → abort and file a separate ticket.
Step 1: Verify Backups Exist
Step 1.5: Create Scratch NS, Copy Creds, Patch NetPol
Step 2: Create Recovery Cluster
WARNING: Never restore into the same namespace as the production cluster. Always use a dedicated scratch ns (e.g.
postgres-restore-test).Full restore (latest available point — replays ALL archived WAL):
Point-in-time restore (specific timestamp):
Add under
bootstrap.recovery:
WARNING: Target time must not exceed the last archived WAL transaction. If it does, Postgres fails with
recovery ended before configured recovery target was reached. Verify target is within mc ls local/postgres-wal/pal-e-postgres/wals/ range first. When in doubt, omit recoveryTarget for full restore.Step 3: Wait for Recovery
Expected duration: For paledocs+twitch2kwager-scale data (~200MB base backup), ready-to-serve takes 45-60 seconds on current k3s+local-path storage. PITR adds ~5 seconds for WAL replay. Larger DBs scale roughly linearly with base-backup size.
Step 4: Verification Queries
Don't just check
COUNT(*) FROM notes — verify against a captured baseline per-DB, per-table.
Step 5a: Cleanup (Dry-Run Drill)
Step 5b: Swap (Real DR — Replacing Production)
- Scale down the app:
kubectl scale deploy -n pal-e-docs pal-e-docs --replicas=0 - Delete the old cluster:
kubectl delete cluster -n postgres pal-e-postgres - Rename the recovery cluster (or update the app's connection string)
- Scale up the app
Note: Renaming a CNPG Cluster is not directly supported. Easier to update the app's
DATABASE_URL to point to the new cluster's service name, or recreate with the original name.Swap hardening (separate ticket): App PDBs, ArgoCD sync lock, service-name collisions — these footguns need a dedicated runbook. Filed as
pal-e-platform improvement ticket.Gotchas
- #1 imageName for RESTORE must be
:17, not the prod tag. Prod cluster currently usesghcr.io/cloudnative-pg/postgresql:17.4-1. RESTORE cluster MUST use:17(plain major tag). CNPG 1.28.1'sbarman-cloud-restoreinvocation passes 4 positional args; the barman-cloud 3.13.0 bundled in:17.4-1expects 3 and errors withunrecognized arguments: /var/lib/postgresql/data/pgdata. The:17tag ships barman-cloud 3.17.0 (Pg 17.9) which works. Verified 2026-04-21 drill. When upgrading prod, test restore-compat in scratch ns FIRST. - #2 serverName is required in externalClusters. Must be
pal-e-postgres(matches backup path in MinIO), regardless of the cluster's metadata.name. - #3 pg_switch_wal() — DANGER in dry-run. Useful during real DR to flush the current WAL segment to S3 before recovery. DO NOT run against prod during dry-run drills — it mutates prod state. Dry-run drills use the latest archived WAL as-is.
- #4 Barman Cloud Plugin migration pending. Native barman-cloud is deprecated in CNPG 1.28, removed in 1.29. See
phase-postgres-4a-barman-plugin-migration. This SOP uses the legacy path; expect to rewrite Step 2 once migration lands. - #5 NetworkPolicy footgun.
default-deny-ingressonminions only whitelists 7 namespaces. Any new restore ns must be added via kubectl patch before the recovery job starts, or the bootstrap pod fails atbarman-cloud-backup-listwith a cryptic endpoint-URL connect error.
See also
validation-postgres-restore-2026-04-21— 2026-04-21 drill verdict + execution logphase-postgres-4-backup-restore— detailed test results and lessonssop-secrets-management— wherecnpg-s3-credsis managed