CloudNativePG backups to object storage, the secure way
Your database backups are perfect: every night, compressed, encrypted, and stored with the same credentials the database uses, in a bucket the database can delete. The ransomware crew will appreciate how tidy they are.
The short answer
Use the Barman Cloud Plugin with an ObjectStore per cluster: a key that can reach only that bucket, TLS verified with endpointCA, AES256 encryption, and a retention policy. Turn on bucket versioning or object lock outside the cluster's control, schedule backups, and restore into a throwaway cluster with a read-only key on a schedule.
On this page
What goes wrong
A backup protects you from two different disasters: losing data by accident, and losing it on purpose because someone attacked you. Most backup setups are built only for the first.
If the database cluster can write, overwrite and delete its own backups, an attacker who controls the cluster, or its credentials, can do the same. Backups also hold everything in the database, including password hashes and personal data, so a readable bucket is a data breach waiting to happen.
The third failure is the quiet one: backups that were never restored. Missing WAL files, a wrong server name or an expired key show up the day you need the restore, not before.
What the docs say
Important Starting with version 1.26, native backup and recovery capabilities are being progressively phased out of the core operator and moved to official CNPG-I plugins.
Source: CloudNativePG 1.30 docs, Backup
The serverName parameter in the ObjectStore resource is retained solely for API compatibility with the in-tree barmanObjectStore and must always be left empty.
Source: Barman Cloud Plugin docs, Usage
The above configuration does not enable WAL archiving for the restored cluster.
Source: Barman Cloud Plugin docs, Usage, Restoring a Cluster
The docs explain the plugin well. What they cannot do is protect the bucket from the cluster: the operator needs a key with write access, so protection against deletion has to live in the object store itself.
The secure configuration
The object store: one per cluster, with a bucket-scoped key, a verified TLS endpoint and encryption at rest. A second, read-only view of the same bucket is for restore tests:
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: db-backups
namespace: team-a
spec:
# How long base backups and WAL are kept; the plugin deletes older ones.
retentionPolicy: "30d"
configuration:
# One bucket prefix per cluster; the bucket itself has versioning on.
destinationPath: s3://db-backups-team-a/
endpointURL: https://s3.example.net
# CA bundle for a self-hosted S3 endpoint, so the TLS connection is verified.
endpointCA:
name: s3-ca
key: ca.crt
s3Credentials:
# A key that can only reach this bucket, stored in a Secret.
accessKeyId:
name: db-backups-s3
key: ACCESS_KEY_ID
secretAccessKey:
name: db-backups-s3
key: ACCESS_SECRET_KEY
wal:
compression: gzip
encryption: AES256 # server-side encryption at rest in the bucket
data:
compression: gzip
encryption: AES256
immediateCheckpoint: false
---
# The same bucket, seen through a READ-ONLY key: used only by restore tests.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: db-backups-readonly
namespace: team-a
spec:
configuration:
destinationPath: s3://db-backups-team-a/
endpointURL: https://s3.example.net
endpointCA:
name: s3-ca
key: ca.crt
s3Credentials:
accessKeyId:
name: db-backups-s3-readonly
key: ACCESS_KEY_ID
secretAccessKey:
name: db-backups-s3-readonly
key: ACCESS_SECRET_KEYThe cluster archives WAL and takes base backups through the plugin:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: db
namespace: team-a
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18
storage:
size: 20Gi
# WAL archiving and base backups through the Barman Cloud Plugin.
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: db-backupsA nightly base backup:
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: db-daily
namespace: team-a
spec:
schedule: "0 15 2 * * *" # 02:15:00 every day (six fields: seconds first)
backupOwnerReference: self
cluster:
name: db
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.ioA restore test, run on a schedule (for example weekly from CI) and deleted afterwards. It reads through the read-only key and does not archive WAL, so it cannot write into the production backup path:
# A throwaway cluster restored from the backups, for a scheduled restore test. Delete it afterwards.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: db-restore-test
namespace: team-a
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:18
storage:
size: 20Gi
bootstrap:
recovery:
source: origin
externalClusters:
- name: origin
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: db-backups-readonly
serverName: dbOutside Kubernetes, on the bucket itself:
- Turn on versioning, and object lock (governance or compliance mode) with a
retention longer than
retentionPolicy, so a deleted or overwritten backup can be recovered. - Give the cluster's key access to this bucket only. Keep the key that can remove object lock or delete versions out of the cluster entirely.
- Replicate the bucket to a second account or provider for off-site copies.
- With object lock, the plugin's retention cannot remove old backups: a delete only adds a delete marker, and the locked version stays until its lock ends. Add a lifecycle rule that expires noncurrent versions after the lock, or storage grows without end.
The plugin needs cert-manager: it registers with the operator only once its
own TLS certificate exists. Then the operator restarts each instance to add
the plugin's sidecar. A Backup created before that fails with
requested plugin is not available: barman-cloud.cloudnative-pg.io.
Prove it
All five resources pass schema validation against the CRDs:
Summary: 5 resources found in 4 files - Valid: 5, Invalid: 0, Errors: 0, Skipped: 0Then on a lab cluster. The object store was MinIO (the Chainguard image) at
https://minio.minio.svc.cluster.local:9000, with a certificate from a
private CA (the s3-ca Secret), a KMS key so encryption: AES256 works,
and the bucket created with object lock and a default GOVERNANCE retention
of 35 days. One key had read and write on this bucket only, the other read
only. The Cluster was the db cluster from
TLS and pg_hba, with the plugins
section added.
1. Is WAL really reaching the bucket? Right after the plugin was added, before its sidecar was running, the bucket was empty, yet:
$ kubectl get cluster -n team-a db -o jsonpath='{.status.conditions[?(@.type=="ContinuousArchiving")].message}'
Continuous archiving is workingThe restore test cluster below, which archives nowhere, reported the same,
with 12 failed attempts in pg_stat_archiver. That condition is not
proof. These are:
$ kubectl get objectstore db-backups -n team-a -o jsonpath='{.status}' | jq .
{
"serverRecoveryWindow": {
"db": {
"firstRecoverabilityPoint": "2026-09-25T04:06:28Z",
"lastSuccessfulBackupTime": "2026-09-25T04:06:28Z"
}
}
}
$ kubectl exec -n team-a db-1 -c postgres -- psql -Atc "select archived_count, failed_count, last_archived_wal, last_archived_time from pg_stat_archiver"
13|6|00000001000000000000000B|2026-09-25 04:11:21.112908+00Alert when last_archived_time is older than your WAL switch interval, or
lastSuccessfulBackupTime older than a day.
2. The backups. An on-demand Backup, and the schedule:
$ kubectl get backup -n team-a
NAME AGE CLUSTER METHOD PHASE ERROR
db-manual-2 21s db plugin completed
$ kubectl describe scheduledbackup -n team-a db-daily | tail -1
Normal BackupSchedule 8m28s cloudnative-pg-scheduledbackup Scheduled first backup by 2026-09-26 02:15:00 +0000 UTCIn the bucket:
2 db/base/20260925T040621
3 db/wals/0000000100000000
$ mc stat lab/db-backups-team-a/db/wals/0000000100000000/000000010000000000000008.gz
VersionID : 10e00b50-dbae-44e3-907b-9eba93984929
Encryption: SSE-S3
X-Amz-Object-Lock-Mode : GOVERNANCE3. What each key can do:
read-only key, read a WAL file: 16088 bytes
read-only key, write an object: mc: <ERROR> Unable to write to one or more targets. Insufficient permissions to access this path ...
read-only key, delete a WAL file: mc: <ERROR> Failed to remove `.../000000010000000000000008.gz`. Access Denied.
backup key, another bucket: mc: <ERROR> Unable to list folder. Access Denied.
backup key, delete a WAL file: Created delete marker `.../000000010000000000000008.gz` (versionId=22a75124-a369-4e06-a7a8-a914350b641d).The backup key can "delete", but the data is still there:
$ mc ls --versions lab/db-backups-team-a/db/wals/0000000100000000/000000010000000000000008.gz
[2026-09-25 04:07:27 UTC] 0B STANDARD 22a75124-a369-4e06-a7a8-a914350b641d v2 DEL 000000010000000000000008.gz
[2026-09-25 03:59:17 UTC] 98KiB STANDARD 10e00b50-dbae-44e3-907b-9eba93984929 v1 PUT 000000010000000000000008.gz
backup key, delete version v1: ... is WORM protected and cannot be overwritten
root key, delete version v1: ... is WORM protected and cannot be overwrittenRemoving the delete marker brought the file back. Governance mode yields
only to a request that asks to bypass it, from a key allowed to
(s3:BypassGovernanceRetention). On a test object, the root key with
mc rm --bypass removed the locked version; without the flag it could not.
Keep any key with that permission out of the cluster, or use compliance mode,
which nobody can bypass.
4. The restore test, through the read-only key:
$ kubectl get cluster -n team-a db-restore-test
NAME AGE INSTANCES READY STATUS PRIMARY
db-restore-test 4m55s 1 1 Cluster in healthy state db-restore-test-1
production: 1234
restored: 1234select count(*) from customers on both. The restored instance ran without
the plugin sidecar (db-restore-test-1: postgres), so it had no way to
write to the backup path.
Mistakes people make
Backups the cluster can delete
A key with delete rights and no object lock means one compromised pod can remove every backup. Put deletion protection in the bucket, outside the cluster's reach.
Keeping the deprecated in-tree barmanObjectStore
Native backups are being phased out. Move to the plugin, and note the
different kubectl cnpg backup --method=plugin syntax in runbooks.
Unverified TLS to a self-hosted S3
Without endpointCA, a self-signed endpoint either fails or gets disabled
"temporarily". Mount the CA and keep verification on.
Restore tests with the production key
A restore test that can write to the backup path can damage it. Use a read-only key, and never enable WAL archiving on the test cluster into the same path.
Never restoring
A backup you have not restored is a hope. Restore on a schedule, check the data, and alert on failure.
Checklist
- Backups use the Barman Cloud Plugin with one ObjectStore per cluster.
- The cluster's key reaches only its own bucket or prefix.
endpointCAis set for self-hosted endpoints.wal.encryptionanddata.encryptionare set.retentionPolicymatches your recovery needs.- The bucket has versioning and object lock controlled outside the cluster.
- A
ScheduledBackupruns at least daily, andContinuousArchivingisTrue. - A scheduled restore test with a read-only key checks real data.
- Backups are replicated off-site.
Backups are the one system whose only job is to work on your worst day. Test them on an ordinary one.
H2-CSPE
Learn it on a live range
Storage and databases, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on databases
Postgres with TLS that verifies, backups you can restore, least-privilege roles and row-level security.
All databases guides