04 - Backup and Restore Strategies for Stateful Workloads
Why this matters
The operator in module 03 protects you against a Pod dying — it fails
over and keeps serving. It does nothing against a dropped table, a bad
migration that corrupts data, a DELETE without a WHERE, ransomware, or
someone kubectl delete namespace-ing the wrong environment. For those you
need backups. But the trap that ends careers isn't not having a backup —
it's having backups that have never been restored and silently don't work.
This module treats "test the restore" as the actual deliverable, because a
backup you can't restore is not a backup, it's a comforting file.
Concepts
Two fundamentally different kinds of backup
There are two families, and they protect against different things:
- Volume snapshots (physical/block-level). A point-in-time copy of the
underlying disk, taken by the storage layer. On Kubernetes this is the
VolumeSnapshotAPI; on Azure it's an Azure Disk snapshot under the hood — the same Azure Disk you provisioned in module 02. Fast to take and restore, whole-volume, and crash-consistent (like pulling the power) — the database must be able to recover from its WAL on restore. Great for "restore the whole database fast"; useless for "restore just one dropped table." - Logical backups (dump-level). A
pg_dump/pg_dumpall— SQL statements (or a custom archive) that recreate the data. Slower, larger relative to data churn, but portable (restore into a different Postgres version, a different cluster, or a managed Azure database — this is how you'll migrate in module 07) and granular (restore a single table). Great for portability and selective restore; slow for terabyte-scale full recovery.
Real setups use both: snapshots (or continuous WAL archiving) for fast full recovery, and periodic logical dumps for portability and fine-grained restores.
Continuous archiving and Point-In-Time Recovery (PITR)
A snapshot or dump captures one moment. PITR lets you restore to any
moment between backups by continuously shipping Postgres's WAL
(write-ahead log) to object storage. Recovery = restore the last base
backup, then replay WAL up to the exact second you choose ("restore to
09:59, one minute before the bad migration at 10:00"). CloudNativePG
(module 03) implements this natively: point a Cluster at an object store
(Azure Blob Storage) for base backups + WAL archiving, and you can recover
to a timestamp. PITR is what turns "we lost everything since last night's
backup" into "we lost 30 seconds."
RPO and RTO: the two numbers that define a backup strategy
Every backup decision reduces to two targets:
- RPO — Recovery Point Objective: how much data you can afford to lose, measured in time. Nightly dumps only → RPO up to 24h (a crash at 23:59 loses the whole day). Continuous WAL archiving → RPO of seconds.
- RTO — Recovery Time Objective: how long recovery is allowed to take. A 2TB logical restore might be an RTO of many hours; a volume snapshot restore might be minutes.
Snapshots optimize RTO (fast restore); WAL archiving optimizes RPO (little data lost); logical dumps optimize portability. You choose the mix by writing down the RPO/RTO the business actually needs — not by backing up "as much as possible."
CloudNativePG's backup CRDs
CNPG turns backups into declarative objects, consistent with the operator model from module 03:
- A
Clustergets aspec.backup.barmanObjectStorepointing at Azure Blob Storage (with credentials, usually via a managed identity — the track 09 / module 05 pattern), enabling base backups + WAL archiving. - A
ScheduledBackupCR runs backups on a cron schedule. - A one-off
BackupCR triggers a backup now. - To restore, you create a new
Clusterwithspec.bootstrap.recoverypointing at the object store (optionally with arecoveryTarget.targetTimefor PITR). Restore-into-a-new-cluster is the safe default: you never overwrite the live one while recovering.
The restore is the deliverable — test it
The single most important idea in this module: an untested backup has an unknown probability of working, which for planning purposes is zero. Backups fail silently for boring reasons — wrong credentials so WAL archiving quietly stopped weeks ago, a snapshot taken while the volume was inconsistent, a dump missing roles/extensions, an object-store lifecycle policy that deleted the base backup out from under the WAL. You find all of these only by actually restoring and querying the data. So a backup process isn't "done" when the backup completes — it's done when a scheduled restore drill completes and the restored data is verified. The diagnose-and-fix below is exactly this: a backup that reports success but can't be restored.
Command reference
| Command | What it does | Example |
|---|---|---|
kubectl get volumesnapshotclass |
Lists snapshot classes (needs the CSI snapshot controller) | kubectl get volumesnapshotclass |
kubectl apply -f <VolumeSnapshot> |
Takes a snapshot of a PVC's volume | see exercises |
kubectl get volumesnapshot |
Shows snapshot readiness (READYTOUSE) |
kubectl get volumesnapshot |
kubectl get backups.postgresql.cnpg.io |
Lists CNPG Backup CRs and their phase |
kubectl get backup |
kubectl cnpg backup <cluster> |
Triggers an on-demand CNPG backup | kubectl cnpg backup pg |
pg_dump / pg_restore |
Logical backup / restore of a database | pg_dump -Fc ... > db.dump |
az snapshot list -o table |
Lists Azure Disk snapshots (billable!) | az snapshot list -g <node-rg> -o table |
az storage blob list |
Inspects WAL/base backups in the Blob container | see module 05 for auth |
Field breakdown — a VolumeSnapshot:
spec.volumeSnapshotClassName— which snapshot class/driver to use (Azure Disk CSI snapshot class).spec.source.persistentVolumeClaimName— the PVC to snapshot (e.g.pg-1's data PVC).- Restore path: create a new PVC with
spec.dataSourcereferencing theVolumeSnapshot— the new volume comes up pre-populated.
Field breakdown — CNPG restore-to-new-cluster (PITR):
spec.bootstrap.recovery.source— the named external cluster / object store to recover from.spec.bootstrap.recovery.recoveryTarget.targetTime— the exact timestamp to replay WAL up to (the "one minute before the bad migration" moment).
Hands-on exercises
Build on module 03's CNPG install. Where a step needs Azure Blob or Azure
Disk snapshots it's marked; the logical-backup and PITR-shape exercises
work on kind too. Namespace db:
kubectl config set-context --current --namespace=db
# Recreate the module 03 cluster if you deleted it:
kubectl apply -f pg.yaml # the 3-instance Cluster from module 03
kubectl get pods -l cnpg.io/cluster=pg
Seed known data so you can verify restores:
PW=$(kubectl get secret pg-app -o jsonpath='{.data.password}' | base64 -d)
kubectl run seed --rm -it --image=postgres:16 --restart=Never -- \
psql "postgresql://app:$PW@pg-rw.db.svc.cluster.local/app" -c \
"CREATE TABLE customers(id serial primary key, name text); \
INSERT INTO customers(name) VALUES ('alice'),('bob'),('carol');"
1. Take a logical backup with pg_dump
kubectl run dump --rm -it --image=postgres:16 --restart=Never -- \
sh -c "pg_dump -Fc 'postgresql://app:$PW@pg-rw.db.svc.cluster.local/app' > /tmp/app.dump && ls -l /tmp/app.dump"
Better, capture it locally so you actually possess the artifact:
kubectl run dump --image=postgres:16 --restart=Never -- \
sh -c "pg_dump -Fc 'postgresql://app:$PW@pg-rw.db.svc.cluster.local/app' > /tmp/app.dump && sleep 30"
kubectl cp db/dump:/tmp/app.dump ./app.dump
kubectl delete pod dump
ls -l ./app.dump
Expected: a real app.dump file on your machine — a portable logical
backup. Note it's a file you now own, independent of the cluster.
2. Simulate a disaster and restore the logical backup
Cause a realistic data-loss event (a DELETE gone wrong):
kubectl run oops --rm -it --image=postgres:16 --restart=Never -- \
psql "postgresql://app:$PW@pg-rw.db.svc.cluster.local/app" -c "DELETE FROM customers;"
Confirm the data is gone, then restore from your dump:
# copy the dump into a client pod and restore it
kubectl run restore --image=postgres:16 --restart=Never -- sleep 300
kubectl cp ./app.dump db/restore:/tmp/app.dump
kubectl exec restore -- sh -c \
"pg_restore --clean --if-exists -d 'postgresql://app:$PW@pg-rw.db.svc.cluster.local/app' /tmp/app.dump"
kubectl exec restore -- psql "postgresql://app:$PW@pg-rw.db.svc.cluster.local/app" -c "SELECT * FROM customers;"
kubectl delete pod restore
Expected: alice, bob, carol are back. You just did the thing most
teams never do — proved the backup restores. Note pg_restore overwrote
the live DB here for simplicity; in production you'd restore into a fresh
database/cluster and cut over.
3. (Azure) Configure CNPG continuous backup to Blob Storage
On AKS, give the Cluster an object store so it does base backups + WAL
archiving (PITR). Create a storage account/container and grant access (a
managed identity is the module 05 pattern; a storage key works for the
lab). Then patch the cluster:
# add to pg.yaml spec:
backup:
barmanObjectStore:
destinationPath: "https://<acct>.blob.core.windows.net/backups"
azureCredentials:
storageAccount:
name: <secret-name>
key: ACCOUNT
storageKey:
name: <secret-name>
key: KEY
retentionPolicy: "7d"
kubectl apply -f pg.yaml
kubectl describe cluster pg | grep -i "archiv\|backup\|wal"
Expected: the cluster reports WAL archiving is working (continuous
archiving OK). On kind, skip this step and read it — the PITR shape in
exercises 4-5 is the transferable idea.
4. Take an on-demand CNPG backup and confirm it completed
kubectl cnpg backup pg 2>/dev/null || cat <<'EOF' | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: { name: pg-backup-1 }
spec: { cluster: { name: pg } }
EOF
kubectl get backup
kubectl describe backup pg-backup-1 | grep -iE "phase|error"
Expected: the Backup CR reaches phase completed. This is exactly the
moment naive teams stop — and it's not enough. Exercise 6 is why.
5. Restore to a new cluster (and PITR shape)
Restore is bootstrap a new Cluster from the backup — never overwrite the live one:
# pg-restore.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-restored
spec:
instances: 1
storage: { size: 1Gi }
bootstrap:
recovery:
source: pg
# For PITR, add:
# recoveryTarget:
# targetTime: "2026-07-26 09:59:00+00"
externalClusters:
- name: pg
barmanObjectStore:
destinationPath: "https://<acct>.blob.core.windows.net/backups"
azureCredentials: { ... } # same as ex 3
kubectl apply -f pg-restore.yaml
kubectl get pods -l cnpg.io/cluster=pg-restored -w
kubectl run verify --rm -it --image=postgres:16 --restart=Never -- \
psql "postgresql://app:$PW@pg-restored-rw.db.svc.cluster.local/app" -c "SELECT count(*) FROM customers;"
Expected: a brand-new cluster pg-restored bootstraps from the backup
and contains the data — recovered without touching the original. With a
targetTime set, it would replay WAL only up to that instant (PITR). On
kind (no object store), study the manifest shape; the "restore into a new
cluster, verify, then cut over" workflow is the point.
6. Diagnose-and-fix: a backup that "succeeded" but can't be restored
This is the module's core lesson. Break WAL archiving after a base backup, then try to restore — reproducing the classic "our backups were green for weeks and none of them worked." Simulate by pointing the backup credentials at a container the cluster can't actually write to (wrong key or missing container), or on kind, by deleting the base backup artifact while WAL keeps referencing it:
# Inspect what CNPG thinks the state is
kubectl describe cluster pg | grep -iE "archiv|error|wal|continuous"
kubectl logs -l cnpg.io/cluster=pg -c postgres --tail=40 | grep -i "archive\|wal\|error\|blob\|auth"
Typical findings when a "successful" backup is actually broken:
- WAL archiving is failing (
archive command failed/ auth error) even though an oldBackupCR showscompleted— so there's a base backup but no continuous WAL after it: PITR beyond the base is impossible. - The object-store retention/lifecycle deleted the base backup, leaving orphan WAL that references nothing to replay onto.
- Credentials rotated; the
BackupCR that "completed" did so before the rotation, and everything since silently failed.
Now prove it by attempting the restore (exercise 5 against this state) — the new cluster fails to bootstrap or comes up missing recent data. Fix:
- Correct the credentials/container so WAL archiving reports
OKagain:kubectl describe cluster pg | grep -i "continuous archiving" # must be OK - Take a fresh base backup now that archiving works, and immediately restore it into a throwaway cluster and query the data before trusting it.
- Institutionalize it: a
ScheduledBackupplus a scheduled restore drill. A backup is only "working" once a restore of it has been verified.
# scheduledbackup.yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata: { name: pg-daily }
spec:
schedule: "0 0 2 * * *" # 02:00 daily (CNPG uses 6-field cron)
cluster: { name: pg }
kubectl apply -f scheduledbackup.yaml
kubectl get scheduledbackup
7. (Azure) Volume snapshot of the data disk, and its cost
# snapshot.yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata: { name: pg-1-snap }
spec:
volumeSnapshotClassName: csi-azuredisk-vsc
source:
persistentVolumeClaimName: pg-1 # the primary's data PVC
kubectl apply -f snapshot.yaml
kubectl get volumesnapshot # wait for READYTOUSE=true
az snapshot list -g <node-rg> -o table
Expected: a VolumeSnapshot becomes ready, and a corresponding Azure
Disk snapshot appears in az snapshot list — and it bills. Restore =
create a new PVC with dataSource referencing pg-1-snap. Snapshots are
fast full-volume recovery (good RTO), crash-consistent, and not
selective. Clean them up (next step) — orphaned snapshots bill like
orphaned disks (module 02).
8. Clean up — including billable snapshots and blobs
kubectl delete cluster pg-restored --ignore-not-found
kubectl delete volumesnapshot pg-1-snap --ignore-not-found
kubectl delete scheduledbackup pg-daily --ignore-not-found
kubectl delete backup --all --ignore-not-found
# Azure-side billable artifacts:
# az snapshot list -g <node-rg> -o table then az snapshot delete --ids <id>
# az storage blob delete-batch ... for the backup container if done
kubectl delete cluster pg --ignore-not-found
kubectl delete pvc -l cnpg.io/cluster=pg --ignore-not-found
# az disk list -g <node-rg> -o table confirm no Retain-ed disks remain
Expected: no lingering clusters, snapshots, or disks. Backups themselves cost money (snapshots, blob storage) — a retention policy that never deletes is its own cost pitfall.
Independent challenge
No full YAML given. Design and prove a recovery for a specific, named
scenario: at 10:00 someone runs a migration that accidentally drops the
customers table, and it isn't noticed until 10:20. Your job is to recover
the table's data as it existed at 09:59, losing as little other data as
possible, without taking the live database offline for the whole
restore. Using CloudNativePG's continuous backup to an object store
(exercise 3) and PITR (exercise 5), restore into a new cluster with a
recoveryTarget.targetTime of 09:59, verify the customers data is
present and correct there, and then describe (you don't have to execute the
cutover) exactly how you'd get that recovered table back into the live
system with minimal disruption. State the RPO and RTO you actually achieved
and how they'd change if you'd had only nightly pg_dumps instead of
continuous WAL archiving. This draws on module 03's operator, this module's
PITR and logical/physical distinction, and forces you to reason about
RPO/RTO rather than "just restore everything."
Stuck? One hint
PITR restores the whole database to 09:59 in a new cluster — that's your
clean source of truth for the dropped table. You don't cut the whole live
system back to 09:59 (that would lose 21 minutes of unrelated good data);
instead you pg_dump just the customers table from the recovered cluster
and load it into the live one. With only nightly dumps, your best recovery
point would be last night's dump (RPO up to ~24h, potentially losing a
day's customers rows) and RTO would be however long a full logical
restore takes — the concrete argument for continuous archiving over dumps
alone.
Common mistakes & troubleshooting
- Treating "backup completed" as "backup works." A green
BackupCR or a finishedpg_dumpproves the write happened, not that the result is restorable. Only a tested restore proves that — schedule restore drills, not just backups. - WAL archiving silently broken after a base backup. Rotated credentials, a wrong container, or a lifecycle policy that deletes base backups leave you with a base you can't replay onto or WAL that references nothing — the exercise 6 failure. Monitor "continuous archiving OK," not just the last backup's status.
- Confusing failover with backup. Module 03's failover survives a dead Pod; it does nothing about a dropped table or bad migration — those are replicated to every replica instantly. Backups are a separate, non-optional layer.
- Restoring over the live database. Restore into a new cluster/DB and verify before cutting over; overwriting the primary during a panic restore is how a recoverable incident becomes an unrecoverable one.
- Snapshots for selective restore. A volume snapshot is whole-volume and crash-consistent — you can't extract "just one table" from it. Use a logical dump when you need granularity.
- Cost pitfall — backups and snapshots bill too. Azure Disk snapshots, blob storage for base backups + WAL, and long retention all cost money; orphaned snapshots bill exactly like orphaned disks (module 02). Set a retention policy and actually delete expired artifacts.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What's the difference between a volume snapshot and a logical
(
pg_dump) backup, and when do you reach for each? - What is PITR, what makes it possible, and what does it buy you over a plain nightly backup?
- Define RPO and RTO, and say which backup approach optimizes which.
- Why is "the
BackupCR showscompleted" not sufficient evidence you can recover? - In exercise 6, name two concrete ways a backup that reported success turns out to be unrestorable.
- Why do you restore into a new cluster rather than over the live one?
- Failover (module 03) already keeps the database running through a Pod failure. Why do you still need backups at all?
Show answers
- A volume snapshot is a fast, whole-volume, crash-consistent copy of the underlying disk — good for quick full recovery, not selective. A logical backup is SQL/archive output — portable across versions/clusters and granular (single table), but slower for full recovery. Use snapshots for fast full restore, dumps for portability/selective restore.
- Point-In-Time Recovery: restoring to any moment by restoring a base backup and replaying continuously-archived WAL up to a chosen timestamp. It's possible because WAL is shipped continuously to object storage. It buys you a tiny RPO (lose seconds, not a whole day) and the ability to stop just before a bad event.
- RPO = how much data (in time) you can lose; RTO = how long recovery may take. Continuous WAL archiving optimizes RPO; volume snapshots optimize RTO; logical dumps optimize portability.
- "Completed" only proves the backup was written, not that it can be read back into a working database — credentials, consistency, missing WAL, or deleted base backups can all make a "successful" backup unrestorable. Only a tested restore proves recoverability.
- Any two: WAL archiving broke (auth/credentials/container) after the base backup so PITR can't proceed; a retention/lifecycle policy deleted the base backup leaving orphan WAL; credentials rotated and everything since the last "completed" backup silently failed; the snapshot was taken while the volume was inconsistent.
- To avoid destroying the live database while recovering — you verify the restored data in isolation and cut over deliberately, so a botched restore doesn't turn a recoverable incident into total loss.
- Failover only protects against instance/Pod failure; it replicates logical damage (dropped table, bad migration, bad DELETE) to every replica instantly. Only backups let you recover from data corruption or accidental destruction.
Next
05-azure-managed-database-services — you've now done the hard, honest work of running and protecting a database yourself. Time to look at the other side of the track: Azure's managed database services, where the provider runs the StatefulSet, the failover, and the tested backups — and you just get a connection string.