CloudNativePG Disaster Recovery¶
New to CNPG? Start with the plain-English, diagram-first Backup/Restore/Start beginner guide — then come back here for the full copy-paste runbook.
This doc is the canonical reference for backing up, restoring, and managing CloudNativePG (CNPG) Postgres clusters in this repository.
Quick links¶
- Concepts — what each piece does
- Repo layout per DB — overlay pattern
- Runbook: fresh DB
- Runbook: restore from Barman
- Runbook: cluster nuke rebuild
- Monitoring and tools
- Troubleshooting and gotchas
Concepts¶
CNPG databases live in two layers:
| Layer | What | Backup mechanism | Restore mechanism |
|---|---|---|---|
| Postgres data | inside the CNPG Cluster CR |
Barman Cloud → RustFS S3 | spec.bootstrap.recovery + externalClusters |
| App state | outside (ExternalSecret, ScheduledBackup) | committed to Git as declarative state | ArgoCD sync |
Barman ≠ PVC backups. The PVC/Kopia system (the kopiur operator, writing to RustFS S3) handles file-level PVC backups. CNPG has its own SQL-aware backup path: Barman Cloud → RustFS S3. The two never touch each other. See docs/disaster-recovery.md for why both exist.
How recovery works (the 30-second version)¶
- Normal operation →
Clusterhasbootstrap.initdb, Postgres comes up empty, Barman writes WAL + scheduled base backups to S3. - DR event → flip the feature flag to
bootstrap.recovery+ specifyexternalClusterspointing at the prior backup lineage; CNPG runsbarman-cloud-restoreon first boot to pull the base backup + replay WAL.
Why "lineage" (-v1, -v2, ...)¶
CNPG requires a clean WAL archive for every new cluster. After a recovery,
the new cluster cannot write WAL to the same S3 directory that the previous
cluster wrote to. So every recovery bumps the serverName by one:
s3://postgres-backups/cnpg/<app>/
├── <app>-database-v1/ ← original / day-0 lineage
│ ├── base/ (full backups)
│ └── wals/ (WAL archive — append-only)
├── <app>-database-v2/ ← prior lineage (restore source)
│ ├── base/
│ └── wals/
└── <app>-database-v3/ ← current write target
├── base/
└── wals/
During DR, you restore FROM one lineage (e.g., v2) and point new backups AT the next (v3). The prior lineage stays untouched as a PITR source for future DR events.
Repo layout per DB¶
Each database directory has a base + two overlays. The root kustomization.yaml
picks the active overlay — this is the DR feature flag.
infrastructure/database/cloudnative-pg/<db>/
├── kustomization.yaml ← FEATURE FLAG. Change this one line to switch modes.
├── externalsecret.yaml ← shared, never edited during DR
├── scheduled-backup.yaml ← shared, never edited during DR
├── base/
│ ├── kustomization.yaml
│ ├── cluster.yaml ← NO `spec.bootstrap`; spec.plugins[] serverName = current write target
│ └── objectstore.yaml ← Barman `ObjectStore` CR (S3 destinationPath, creds, retention)
└── overlays/
├── initdb/
│ ├── kustomization.yaml
│ └── bootstrap-patch.yaml ← strategic merge: adds bootstrap.initdb
└── recovery/
├── kustomization.yaml
└── bootstrap-patch.yaml ← strategic merge: adds bootstrap.recovery + externalClusters
Root kustomization.yaml — the feature flag¶
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cloudnative-pg
labels: [ ... ] # (was commonLabels — migrated repo-wide 2026-07-09)
resources:
# Comment one, uncomment the other. That IS the DR switch.
- overlays/initdb # ← fresh DB, no restore
# - overlays/recovery # ← pull from Barman on first boot
- externalsecret.yaml
- scheduled-backup.yaml
base/cluster.yaml — everything except bootstrap¶
The base Cluster manifest contains all immutable spec (image, resources,
storage, monitoring, backup target). Backups are plugin-based: spec.plugins[]
with name: barman-cloud.cloudnative-pg.io, isWALArchiver: true, and
parameters.barmanObjectName pointing at the sibling ObjectStore CR
(base/objectstore.yaml), which owns the S3 destinationPath, credentials,
and retention. spec.plugins[0].parameters.serverName in base is always the
write target for new backups — bump this when you bump the lineage. Do not
add an in-tree spec.backup.barmanObjectStore field; it does not exist in CNPG
1.30.0+.
overlays/initdb/bootstrap-patch.yaml¶
Strategic-merge patch adding spec.bootstrap.initdb with database, owner,
secret, and any postInitApplicationSQL (extensions, grants, initial data).
overlays/recovery/bootstrap-patch.yaml¶
Adds spec.bootstrap.recovery pointing at a named externalClusters entry,
which in turn points at the prior lineage on S3. Optionally includes
recoveryTarget.targetTime for point-in-time recovery. Do not set a
targetTime beyond the last archived WAL — Postgres will FATAL with
"recovery ended before configured recovery target was reached."
Runbook: fresh DB (initdb)¶
New day-zero app, no data to restore:
- Edit root
kustomization.yaml→overlays/initdbactive. - Ensure
base/cluster.yamlspec.plugins[0].parameters.serverName=<db>-database-v1(andparameters.barmanObjectNamereferences the<db>-objectstoreCR inbase/objectstore.yaml). - Ensure
overlays/initdb/bootstrap-patch.yamlhas your database name, owner, secret, postInitApplicationSQL. git add / commit / push.- ArgoCD syncs, CNPG operator creates Cluster with
bootstrap.initdb, Postgres comes up empty, scheduled backups start writing to<db>-database-v1/on S3.
If this is a rebuild of a database whose S3 path already exists, do not reuse
its previous serverName. Advance to a clean lineage and move the recovery
overlay to the superseded lineage in the same commit. Otherwise Barman rejects
WAL archiving with Expected empty archive.
Runbook: restore from Barman (recovery)¶
In-place disaster recovery — an existing DB has bad/corrupt data (or is empty after a cluster nuke) and you want to restore from backups.
Critical facts:
- CNPG evaluates
spec.bootstraponly at Cluster creation. To force a fresh bootstrap, you MUST delete the live Cluster + its PVCs and let ArgoCD re-create it. kubectl delete clusterdoes NOT delete PVCs — CNPG leaves them as a data-protection measure. You must explicitly delete the PVCs.
Steps¶
1. Bump lineage versions in Git.
# base/cluster.yaml — bump write target to the NEW lineage
spec:
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: <db>-objectstore # the prod ObjectStore CR (base/objectstore.yaml)
serverName: <db>-database-vN # N = new lineage, e.g. v2 → v3
# overlays/recovery/bootstrap-patch.yaml — point externalClusters at the PRIOR lineage
spec:
bootstrap:
recovery:
source: <db>-recovery-source
database: <db> # required — CNPG defaults to "app" otherwise
owner: <owner>
secret:
name: <db>-app-secret
# Optional PITR — ONLY if you know WAL exists at that timestamp
# recoveryTarget:
# targetTime: "2026-04-17T23:59:59Z"
externalClusters:
- name: <db>-recovery-source
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
# Reuse the SAME prod ObjectStore CR as live backups (same bucket,
# creds, endpoint — destinationPath/endpointURL/s3Credentials all live
# on the ObjectStore, NOT inline here). Only serverName differs: it
# selects the PRIOR lineage subtree to restore from.
barmanObjectName: <db>-objectstore
serverName: <db>-database-v(N-1) # N-1 = the lineage with good data
The
serverNamehere selects the prior backup lineage on the same RustFS bucket. Verify that lineage is still within the S3 recovery window — older lineages age out of RustFS lifecycle retention and become unrestorable. All DBs use theplugin:shape (no in-treeexternalClusters[].barmanObjectStore).
2. Flip the feature flag.
# root kustomization.yaml
resources:
# - overlays/initdb
- overlays/recovery # ← activate recovery
- externalsecret.yaml
- scheduled-backup.yaml
3. Commit and push.
git add infrastructure/database/cloudnative-pg/<db>/
git commit -m "dr(<db>): flip to recovery — restore from vN-1, write to vN"
git push
4. Hard-refresh the ArgoCD app FIRST so its manifest cache matches the
new git state. Without this, ArgoCD may re-create the deleted Cluster
from a stale rendered manifest (pre-recovery-flip) — you'll get a fresh
empty database with bootstrap.initdb and serverName: v(N-1) despite
git being correct.
kubectl annotate application <db> -n argocd \
argocd.argoproj.io/refresh=hard --overwrite
# Verify ArgoCD now sees the Cluster as OutOfSync (proves cache picked
# up the new bootstrap.recovery + new serverName)
kubectl get application <db> -n argocd \
-o jsonpath='{.status.resources[?(@.kind=="Cluster")].status}{"\n"}'
# Expect: OutOfSync
5. Delete the live Cluster + PVCs (forces CNPG to re-evaluate bootstrap).
kubectl -n cloudnative-pg delete cluster <db>-database
kubectl -n cloudnative-pg delete pvc -l cnpg.io/cluster=<db>-database
# Wait for Longhorn to finish terminating the PVCs (~30–90s)
kubectl -n cloudnative-pg get pvc -l cnpg.io/cluster=<db>-database
6. Trigger ArgoCD sync.
kubectl -n argocd patch application <db> --type merge \
-p '{"operation":{"sync":{"revision":"HEAD"}}}'
7. Watch the recovery.
kubectl -n cloudnative-pg get cluster <db>-database -w
kubectl -n cloudnative-pg get pods | grep <db>
# Once a <db>-database-1-full-recovery-* pod is Running, tail its logs
kubectl -n cloudnative-pg logs <db>-database-1-full-recovery-xxxxx -f
Look for:
"restored log file \"...\" from archive"— WAL being pulled"consistent recovery state reached at ..."— success signal"recovery ended before configured recovery target was reached"— FATAL, means yourrecoveryTarget.targetTimeis beyond last archived WAL. Remove the target or pick an earlier one.
8. Verify data.
kubectl exec -n cloudnative-pg <db>-database-1 -c postgres -- \
psql -U postgres -d <db> -c "\dt" # should show restored tables
9. Restart the consumer app so it picks up the fresh DB connection.
10. (Optional) Flip back to initdb. Once the Cluster exists and is running
with the recovered data, spec.bootstrap is a no-op — CNPG ignores it on
existing Clusters. You can leave the overlay on recovery, or flip the root
kustomization.yaml back to overlays/initdb for a tidier "steady state" git
declaration. Both are valid.
Runbook: cluster nuke rebuild¶
Entire K8s cluster was rebuilt, ArgoCD bootstrapping fresh, CNPG DBs need to come back:
- If Barman S3 still has usable backups → set root
kustomization.yamltooverlays/recoverybefore ArgoCD first-syncs the DB. The AppSet will create each Cluster withbootstrap.recoveryon initial creation — no delete/recreate dance needed. - If Barman S3 is empty or unreliable → use
overlays/initdband rebuild the DB fresh. Apps will re-migrate their schemas on first connect.
Post-bootstrap app rollout. After DBs come up, the apps that talk to them may still be pointing at stale connections and need a rolling restart:
for ns in gitea immich paperless-ngx temporal; do
kubectl -n "$ns" get deploy --no-headers | awk '{print $1}' | \
xargs -I {} kubectl -n "$ns" rollout restart deployment/{}
done
Monitoring and tools¶
Use these first:
- ArgoCD UI (http://localhost:39681 or https://argocd.vanillax.me) Shows sync/health status per DB app. Good for "is this DB's git in sync with cluster?"
- Grafana (https://grafana.vanillax.me) via kube-prometheus-stack The CNPG Helm chart ships with Grafana dashboards — check for panels under "CloudNativePG" folder. Covers backup timing, WAL archiving, Cluster state. If missing, import from https://github.com/cloudnative-pg/grafana-dashboards.
- K8sGPT (in
monitoring/k8sgpt/) — detects CNPG Cluster anomalies and surfaces them in its dashboard. - Headlamp (https://headlamp.vanillax.me) — generic K8s UI, can view CNPG Cluster CRDs, pods, events. Good for "why is this DB stuck?"
kubectl cnpg plugin(install locally): Shows replication lag, backup timing, WAL position, recovery progress — all in a colored terminal view. This is the single best CLI tool for CNPG health.
State visibility quick-check (copy-paste this script):
for db in gitea immich paperless temporal; do
echo "--- $db ---"
kubectl -n cloudnative-pg get cluster "$db-database" \
-o jsonpath=' mode={.spec.bootstrap.*}{"\n"} serverName={.spec.plugins[0].parameters.serverName}{"\n"} ready={.status.readyInstances}/{.spec.instances}{"\n"} phase={.status.phase}{"\n"}'
echo
done
Future improvements (ideas to come back to)¶
Optional tooling to build when DR becomes a routine drill (quarterly) or painful enough that the tools are worth it. Rough-ordered by effort-vs-payoff.
Tier 1 — quick wins (do first when you have 30 min)¶
-
Import the official CNPG Grafana dashboards. Upstream publishes ready-made JSON at https://github.com/cloudnative-pg/grafana-dashboards. Drop into
monitoring/prometheus-stack/as ConfigMaps with the Grafana sidecar label so they auto-import. Covers: backup age per cluster, WAL archiving lag, connection count, checkpoint stats. One-time commit, forever-on visibility. -
Install the
kubectl cnpgplugin locally. Single best tool for CNPG state. Pin this as a prerequisite in onboarding. -
Committed state-check script in
scripts/that prints a summary table of all CNPG DBs: current serverName, last successful backup, last WAL archive age, Cluster phase. Expands the inline script above into a standalone tool with nicer formatting. ~30 lines of bash.
Tier 2 — DR wizard CLI (weekend project, ~1-2 days)¶
A thin local CLI (scripts/dr-wizard) that turns the full DR runbook into
guided steps. Minimum viable feature set:
dr-wizard status— reads git + live state, prints "here's each DB's current lineage, mode flag, backup age."dr-wizard plan <db>— dry-run: available lineages on S3, proposed serverName changes, the diff, ready to open a PR.dr-wizard execute <db>— after PR merged, performs the destructive kubectl delete cluster + PVC + sync step with y/N confirmations.dr-wizard validate <db>— post-recovery, runs SQL sanity check (counts rows in a few tables, reports vs. previously-known counts).
Worth it IF DR becomes routine: collapses a 30-minute copy-paste dance into ~3 commands with built-in guardrails (WAL range check, lineage math, consumer-app restart). Not worth building for a once-a-year use case.
Scope creep to avoid: don't build a web UI. CLI + GitHub PR checkout is already a UI. Just make the CLI nice.
Tier 3 — proper state-management UI (only if scale grows)¶
If the cluster grows to 10+ CNPG DBs, revisit with a real web interface:
- Adopt an existing tool first. Check whether CNPG has an upstream dashboard project by the time this matters. If yes, use that.
- Custom web UI (last resort). Only build if nothing upstream exists AND DR is happening monthly+. A Next.js dashboard reading the Cluster CRDs, showing backup lineage timelines per DB, offering the same wizard actions the CLI has. Huge maintenance tax.
Explicitly NOT worth building¶
- General-purpose Postgres management GUI (pgAdmin, Adminer, DBeaver server, etc.). They operate at the SQL layer, not the CNPG Cluster lifecycle you care about during DR. Install locally as a client tool if useful for ad-hoc queries — but they add zero value for DR.
- Lua / Helm-hook automation around the delete-cluster-PVC step. The
manual
kubectlsequence is already 2 commands and explicitly destructive; hiding it behind automation just makes "oops I meant the other DB" blastier. - Automated PITR-target guessing (e.g. "restore to yesterday 23:59"). Always specify targets explicitly or omit them entirely. Guesswork here produces the "recovery ended before target" FATAL.
Troubleshooting and gotchas¶
"recovery ended before configured recovery target was reached"¶
Your recoveryTarget.targetTime is AFTER the last archived WAL on S3.
Remove the target (restore to latest-WAL-available) OR pick an earlier
timestamp. Symptom: full-recovery pods CrashLoopBackOff with this FATAL in
the Postgres log.
barman-cloud-check-wal-archive: Expected empty archive¶
The new forward write lineage is already dirty. Do not reuse that
serverName, and do not delete random RustFS objects unless you have already
identified the exact abandoned prefix. The safe recovery is:
- Keep the recovery source pointed at the last known-good lineage.
- Bump
base/cluster.yamlspec.plugins[0].parameters.serverNameto the next clean forward lineage. - Hard-refresh Argo before deleting the Cluster/PVCs.
- Delete the live Cluster, recovery Jobs, and CNPG PVCs.
- Let Argo recreate the Cluster from the current render.
"relation does not exist" after a successful recovery¶
The restored DB is empty (or has a subset of data). Common causes: - Barman base backup was taken BEFORE the app had populated its tables. - WAL archive had a gap (archiving was broken for some period). Check with:
kubectl exec -n cloudnative-pg <db>-database-1 -c postgres -- \
psql -U postgres -c "SELECT count(*) FROM pg_tables WHERE schemaname='public';"
New Cluster comes up with bootstrap.initdb despite git saying recovery¶
ArgoCD's ignoreDifferences on .spec.bootstrap + RespectIgnoreDifferences=true
strips the bootstrap field during apply. The database AppSet must NOT have
.spec.bootstrap in its jqPathExpressions — verify
infrastructure/controllers/argocd/apps/appsets/database-appset.yaml. If it
does, ArgoCD is silently dropping your recovery config.
Sync "Succeeded" but Cluster doesn't appear¶
The DB's ArgoCD Application may carry a argocd.argoproj.io/skip-reconcile: "true"
annotation. ArgoCD reports sync success but never actually reconciles. Fix:
PVCs stuck in Terminating¶
Longhorn cleanup sometimes takes >60s when many volumes delete concurrently. If they stay Terminating >5 min:
# Check Longhorn volumes
kubectl -n longhorn-system get volumes.longhorn.io | grep <cluster-name>
# If the Longhorn volume is detached but PVC is stuck, it's a K8s finalizer —
# last resort, remove finalizer manually:
kubectl -n cloudnative-pg patch pvc <pvc-name> --type=merge -p '{"metadata":{"finalizers":[]}}'
ExternalSecret says Synced but the actual Secret is missing¶
The ExternalSecret status lags when the Secret was deleted externally. Force a re-sync:
If the ES itself has a stuck deletion finalizer:
kubectl -n cloudnative-pg patch externalsecret <name> \
--type=merge -p '{"metadata":{"finalizers":[]}}'
Polluted S3 lineage after a failed DR attempt¶
If post-DR scheduled backups wrote empty base backups to the wrong
serverName, the cleanest fix is:
- Leave the known-good recovery source alone.
- Bump
base/cluster.yamlspec.plugins[0].parameters.serverNameto the next clean forward lineage. - Let the next scheduled backup populate the clean prefix.
Only wipe an abandoned RustFS prefix after confirming no live Cluster points at it as a write target or recovery source.
Deprecation / forward migration¶
spec.monitoring.enablePodMonitor deprecated¶
Used by all DBs. A future CNPG release removes it. Migration: replace with a
manually-managed PodMonitor resource per cluster. Not urgent, but note the
warning in CNPG logs.