The question that comes up after a bad deploy is never "do we have a backup". It is "can you put it back the way it was at 10.42, just before the migration ran". A nightly dump answers that with a shrug and up to twenty four hours of lost writes. Continuous archiving answers it with a timestamp.
The mechanism is a periodic base backup plus every write ahead log segment shipped to object storage as it is closed. Recovery restores the base backup and replays the log up to the second you name. Your recovery point objective drops from a day to the last archived segment.
Pick the operator, then check which era of its configuration you are in
CloudNativePG is the operator most teams settle on for Postgres on Kubernetes, and there is one thing to know before copying any manifest you find. Native backup and recovery is being progressively phased out of the core operator and moved into official CNPG-I plugins. The in-tree barmanObjectStore method is deprecated starting with v1.26 in favour of the Barman Cloud Plugin, and spec.backup.retentionPolicy on the Cluster is deprecated as well.
Both still work for backward compatibility, which is exactly why so much published YAML is quietly out of date. Write new clusters against the plugin.
Install the plugin
The plugin needs CloudNativePG 1.26 or newer and cert-manager already installed, and it is deployed into the operator's namespace, typically cnpg-system.
kubectl apply -f \
https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.14.0/manifest.yaml
Check the plugin's releases page for the current version rather than pinning to that one forever.
Describe the bucket once
The plugin introduces an ObjectStore resource, so bucket details and credentials are declared in one place and referenced by any cluster that needs them.
# k8s/pg-objectstore.yaml
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: pg-backups
spec:
configuration:
destinationPath: s3://backups/
endpointURL: http://minio:9000
s3Credentials:
accessKeyId:
name: minio
key: ACCESS_KEY_ID
secretAccessKey:
name: minio
key: ACCESS_SECRET_KEY
wal:
compression: gzip
The credential blocks point at a Kubernetes Secret, so the Secret name and both keys have to match what you create.
kubectl create secret generic minio \
--from-literal=ACCESS_KEY_ID=REPLACE_ME \
--from-literal=ACCESS_SECRET_KEY=REPLACE_ME
Use a bucket in a different account or project from the one running the cluster, with credentials that cannot delete objects. A single set of leaked keys that can both drop the database and empty the backup bucket leaves you with one copy, not two.
Point the cluster at it
The Cluster references the store through spec.plugins, and isWALArchiver is the flag that turns on continuous archiving rather than only base backups.
# k8s/pg-cluster.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-main
spec:
instances: 3
storage:
size: 50Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: pg-backups
Retention is configured on the ObjectStore rather than on the Cluster now that the in-tree retention field is deprecated. Read the plugin's own API reference for the current field names instead of carrying an old spec.backup.retentionPolicy block across.
Base backups on a schedule
An on-demand backup is a Backup object with the plugin named in pluginConfiguration.
# k8s/pg-backup-now.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: backup-example
spec:
cluster:
name: pg-main
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
Or imperatively, if the cnpg kubectl plugin is installed:
kubectl cnpg backup -n default pg-main \
--method=plugin \
--plugin-name=barman-cloud.cloudnative-pg.io
The recurring version is a ScheduledBackup, and its schedule field is the one that trips people up. It takes a six field cron expression that includes seconds as the first entry, which is not the format Kubernetes CronJob uses. The expression below runs at 02:00:00 daily, not at two seconds past midnight.
# k8s/pg-scheduled-backup.yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pg-main-nightly
spec:
schedule: "0 0 2 * * *"
backupOwnerReference: self
cluster:
name: pg-main
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
By default CloudNativePG takes the backup from the most up to date replica and falls back to the primary if no replica is available, which keeps the base backup off the instance serving your traffic.
Watch the archive, not only the backup job
A base backup that succeeded last night says nothing about whether the write ahead log has been shipping since. When archiving fails, Postgres keeps retrying periodically until it succeeds, and in the meantime the pg_wal directory continues to fill with WAL segment files. If the filesystem containing pg_wal fills up, Postgres does a PANIC shutdown. No committed transactions are lost, but the database stays offline until space is freed, so a broken archive eventually becomes an outage on its own schedule.
Two signals are worth an alert. Free space on the data volume, and the age of the newest object in the backup bucket. If the newest archived segment is older than your archive timeout, your recovery point has quietly stopped moving forward while every pod stays Running and every probe stays green.
Recovering to a timestamp
Recovery in CloudNativePG is not performed in place on an existing cluster. It bootstraps a new cluster from a physical backup, which is a better default than it first looks, because the damaged cluster stays untouched while you work. A valid WAL archive is required for point in time recovery, so this only works if archiving was running before the incident.
# k8s/pg-restore-to-timestamp.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-main-restored
spec:
instances: 1
storage:
size: 50Gi
bootstrap:
recovery:
source: origin
recoveryTarget:
targetTime: "2026-08-20 10:42:00.00000+00"
externalClusters:
- name: origin
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: pg-backups
serverName: pg-main
serverName is the original cluster's name, which is how the plugin finds the right series of backups in the bucket. Always put an explicit timezone in the timestamp. The documentation warns about ambiguity here for good reason, since an hour either way is usually the whole point of the exercise.
Once the restored cluster is up, connect to it and check the data before you point anything at it. Then decide whether to promote it or to export the rows you actually needed.
Prove it on a Tuesday
Run a restore to an arbitrary timestamp once a quarter and write down how long it took from decision to usable database. That number is your real recovery time objective. Everything else is a claim.
If you want this built, monitored and rehearsed on your own cluster, that is what our Kubernetes management and disaster recovery and backup engagements do.
Talk to the engineer who will own your stack.
No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.
Related Articles
AWS Cost Optimization: 10 Things You're Probably Overpaying For
Ten common areas where AWS customers overspend, with practical strategies for right-sizing, reserved capacity, storage lifecycle management, and more.
CloudCloudflare Tunnel vs AWS ALB: When to Use Which
An architecture comparison of Cloudflare Tunnel and AWS Application Load Balancer, covering cost, DDoS protection, SSL termination, latency, and setup complexity.
CloudAWS Cost Optimization Strategies for Growing SaaS
Reduce your AWS bill by 30-50% with Reserved Instances, Spot Fleets, right-sizing, and architectural patterns designed for cost-efficient SaaS growth.