Databases

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.

Updated Houssam Hammoudi, CTOTested with CloudNativePG 1.30.1, Barman Cloud Plugin 0.15.0, PostgreSQL 18, MinIO RELEASE.2026-09-22 (SSE-S3, object lock), Kubernetes 1.34 (kind)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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:

yaml
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_KEY

The cluster archives WAL and takes base backups through the plugin:

yaml
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-backups

A nightly base backup:

yaml
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.io

A 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:

yaml
# 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: db

Outside 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:

text
Summary: 5 resources found in 4 files - Valid: 5, Invalid: 0, Errors: 0, Skipped: 0

Then 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:

text
$ kubectl get cluster -n team-a db -o jsonpath='{.status.conditions[?(@.type=="ContinuousArchiving")].message}'
Continuous archiving is working

The 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:

text
$ 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+00

Alert 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:

text
$ 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 UTC

In the bucket:

text
      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             : GOVERNANCE

3. What each key can do:

text
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:

text
$ 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 overwritten

Removing 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:

text
$ 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:   1234

select 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.
  • endpointCA is set for self-hosted endpoints.
  • wal.encryption and data.encryption are set.
  • retentionPolicy matches your recovery needs.
  • The bucket has versioning and object lock controlled outside the cluster.
  • A ScheduledBackup runs at least daily, and ContinuousArchiving is True.
  • 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 free

The Secure Way

More on databases

Postgres with TLS that verifies, backups you can restore, least-privilege roles and row-level security.

All databases guides