Production Postgres on Kubernetes with CloudNativePG and Cloudflare R2 Backups

A complete, reusable setup - CloudNativePG, the Barman Cloud Plugin, and daily backups to Cloudflare R2 with a 3-day retention window

Introduction

CloudNativePG (CNPG) is a CNCF Postgres operator: instead of hand-rolling a StatefulSet and wiring up replication, failover, and backups yourself, a Cluster custom resource declares the desired state and the operator’s reconcile loop drives real Postgres instances toward it - primary election, streaming replication, and continuous WAL archiving all included. Backup and recovery are handled by a plugin (the Barman Cloud Plugin, covered below) that ships base backups and WAL segments to any S3-, Azure-, or GCS-compatible object store.

This guide builds a complete, reusable production setup: a 3-instance Postgres cluster, continuous WAL archiving, and a daily scheduled backup to Cloudflare R2 with 3-day retention - a genuinely low-cost setup, since R2 charges nothing for egress and a 3-day recovery window keeps stored volume small. Every manifest below is real and was checked against the current CloudNativePG and Barman Cloud Plugin source (operator v1.30.0, plugin v0.14.0) - adjust the placeholders (bucket name, account ID, storage class, resource sizing) for your own cluster.

Prerequisites

  • A Kubernetes cluster with a working default (or named) StorageClass
  • kubectl, and optionally the cnpg plugin for it: kubectl krew install cnpg
  • cert-manager - required by the Barman Cloud Plugin (not by CNPG itself) to secure communication between the plugin and the operator
  • A Cloudflare account with an R2 bucket created, and your Cloudflare Account ID

Installing CloudNativePG

kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml

kubectl rollout status deployment -n cnpg-system cnpg-controller-manager

Check the releases page for the current minor version before running this - the manifest URL is versioned, unlike a latest tag.

Installing cert-manager and the Barman Cloud Plugin

The plugin requires cert-manager and CloudNativePG 1.26 or newer, and must be installed in the same namespace as the operator (cnpg-system by default):

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
kubectl apply -f \
  https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.14.0/manifest.yaml

kubectl rollout status deployment -n cnpg-system barman-cloud

Again, check the plugin’s releases page for the current version rather than assuming v0.14.0 is still latest by the time you’re reading this.

Getting Cloudflare R2 Credentials

From the Cloudflare dashboard: create (or reuse) an R2 bucket, then under R2’s API token management, generate an Object Read & Write token scoped to that bucket. This gives you an Access Key ID and Secret Access Key - R2’s S3-compatible API. R2 has no concept of AWS-style regions, but the S3 SDK the plugin uses still requires a region value; Cloudflare’s own guidance is to use the literal string auto.

R2’s S3 API endpoint is https://<ACCOUNT_ID>.r2.cloudflarestorage.com, where <ACCOUNT_ID> is your Cloudflare account ID (found in the dashboard sidebar).

kubectl create secret generic r2-backup-creds \
  --namespace production \
  --from-literal=ACCESS_KEY_ID='<your R2 access key id>' \
  --from-literal=ACCESS_SECRET_KEY='<your R2 secret access key>' \
  --from-literal=REGION='auto'

Defining the ObjectStore

The Barman Cloud Plugin uses a dedicated ObjectStore custom resource - one per object store, referenced by name from the Cluster:

apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: r2-backup-store
  namespace: production
spec:
  retentionPolicy: "3d"
  configuration:
    destinationPath: "s3://YOUR_BUCKET_NAME/prod-postgres"
    endpointURL: "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
    s3Credentials:
      accessKeyId:
        name: r2-backup-creds
        key: ACCESS_KEY_ID
      secretAccessKey:
        name: r2-backup-creds
        key: ACCESS_SECRET_KEY
      region:
        name: r2-backup-creds
        key: REGION
    wal:
      compression: gzip
    data:
      compression: gzip

retentionPolicy: "3d" is a recovery window, not a fixed backup count: the plugin computes the Point of Recoverability as now - 3 days, keeps the most recent backup completed before that point (the “first valid backup”) plus everything newer, and marks anything older obsolete for deletion after the next successful backup. This is what actually keeps R2 storage cheap - only a rolling ~3 days of base backups and WALs are ever retained, not an ever-growing archive.

endpointURL plus s3Credentials is exactly the same mechanism the plugin uses for MinIO, DigitalOcean Spaces, or Linode Object Storage - R2 isn’t special-cased, it’s just another S3-compatible endpoint. region has to be a Secret reference (not an inline string) in this CRD, which is why the credentials Secret above includes a REGION key rather than hardcoding auto in the YAML.

The Production Cluster

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: prod-postgres
  namespace: production
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-system-trixie
  primaryUpdateStrategy: unsupervised

  bootstrap:
    initdb:
      database: appdb
      owner: appuser
      secret:
        name: prod-postgres-app-user

  enableSuperuserAccess: true
  superuserSecret:
    name: prod-postgres-superuser

  storage:
    storageClass: YOUR_STORAGE_CLASS
    size: 20Gi

  resources:
    requests:
      cpu: "1"
      memory: 2Gi
    limits:
      cpu: "2"
      memory: 4Gi

  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname

  monitoring:
    enablePodMonitor: true

  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: r2-backup-store

A few fields worth understanding, not just copying:

  • bootstrap.initdb.secret references a Secret you create separately (kubernetes.io/basic-auth type, with username/password keys) for the application database user - CNPG creates the database and user from it on cluster initialization, rather than you running CREATE USER by hand.
  • superuserSecret does the same for the postgres superuser; enableSuperuserAccess: true is required for CNPG to actually configure that login (it’s disabled by default, on the reasonable assumption most applications shouldn’t need superuser).
  • affinity.enablePodAntiAffinity + topologyKey: kubernetes.io/hostname spreads the 3 instances across different nodes - without it, Kubernetes’ scheduler has no reason to avoid stacking all 3 Postgres pods on one node, which would defeat the purpose of running 3 replicas.
  • monitoring.enablePodMonitor: true has the operator create a PodMonitor automatically, wiring the cluster into an existing Prometheus Operator setup with no extra YAML.
  • plugins[].isWALArchiver: true is what turns on continuous WAL shipping to the ObjectStore - without it, the plugin is available for on-demand/scheduled base backups but WALs between backups aren’t archived, which means recovery is only possible to the point of the last full backup, not to any point in time.

Pin imageName to a specific tag (as above) rather than leaving it unset - unset, CloudNativePG’s admission webhook fills in whatever Postgres image is currently configured as the operator’s default, which is a form of drift you don’t control from your own manifest. Check the image catalog for the current recommended tag before deploying.

Scheduling Daily Backups

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: prod-postgres-daily-backup
  namespace: production
spec:
  schedule: "0 0 3 * * *"
  backupOwnerReference: cluster
  cluster:
    name: prod-postgres
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io

CNPG’s schedule field is a 6-field cron expression with seconds (not the standard 5-field Kubernetes CronJob format) - "0 0 3 * * *" means second 0, minute 0, hour 3: 03:00:00 UTC daily. backupOwnerReference: cluster makes the Cluster the Kubernetes owner of every Backup object this schedule creates, so they’re automatically garbage-collected if the cluster itself is ever deleted.

Verifying Everything Works

kubectl get objectstore -n production
kubectl get cluster prod-postgres -n production
kubectl get scheduledbackup -n production
kubectl get backup -n production
kubectl cnpg status prod-postgres -n production

kubectl cnpg status (from the cnpg kubectl plugin) is the fastest way to see WAL archiving status, replication lag, and point-of-recoverability in one view, rather than piecing it together from several kubectl get/describe calls.

To trigger an immediate on-demand backup without waiting for the schedule (useful right after setup, to confirm the whole path actually works):

kubectl cnpg backup -n production prod-postgres \
  --method=plugin \
  --plugin-name=barman-cloud.cloudnative-pg.io

Then confirm the backup actually landed in R2 - the Cloudflare dashboard’s bucket browser, or an S3-compatible CLI (aws s3 ls --endpoint-url https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com s3://YOUR_BUCKET_NAME/prod-postgres/), should show WAL segments and a base backup under the configured path.

Restoring to a New Cluster

Disaster recovery means creating a new Cluster that bootstraps from the object store rather than from scratch:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: prod-postgres-restored
  namespace: production
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-system-trixie
  bootstrap:
    recovery:
      source: prod-postgres-backup
  externalClusters:
    - name: prod-postgres-backup
      plugin:
        name: barman-cloud.cloudnative-pg.io
        parameters:
          barmanObjectName: r2-backup-store
          serverName: prod-postgres
  storage:
    storageClass: YOUR_STORAGE_CLASS
    size: 20Gi

externalClusters[].plugin.parameters.serverName must match the original cluster’s name (prod-postgres) - it’s how the plugin locates that cluster’s specific backups and WAL history inside a bucket path that could in principle hold data for more than one cluster. This configuration alone restores data but does not re-enable WAL archiving for the new cluster; add a .spec.plugins block (pointing at the same or a different ObjectStore) alongside externalClusters if the restored cluster should keep archiving going forward, the same way the original prod-postgres Cluster does.

Actually test this periodically. A backup you’ve never restored from is a hope, not a disaster recovery plan - run the restore above against a scratch namespace on a schedule and confirm the data is actually there and current.

Why This Is Genuinely Cheap

  • R2 has no egress fees - the cost driver that makes AWS S3/Glacier retrieval and cross-region backup expensive simply doesn’t exist here; you pay for storage and (minimal, at this scale) request volume only.
  • A 3-day recovery window keeps storage small - only a rolling few days of base backups plus WALs are retained at any time, not months of accumulated history.
  • One ObjectStore can back multiple clusters - a shared bucket with per-cluster path prefixes (as in destinationPath above) avoids provisioning separate storage per database.
  • No managed-database markup - you’re running real Postgres, with real replication and point-in-time recovery, on infrastructure you already have, without a managed-Postgres provider’s per-vCPU/per-GB premium.

This is a good fit for small-to-mid production workloads where a few days of point-in-time recovery is an acceptable RPO. It is not a substitute for cross-region redundancy or a longer compliance-driven retention window - both are achievable (a second ObjectStore in a different provider/region, a longer retentionPolicy) but change the cost equation this setup is optimized around.

Best Practices

  1. Test restores, not just backups - a ScheduledBackup that’s been “succeeding” for months with a broken recovery path is a false sense of security until the day it matters.
  2. Pin imageName explicitly and track the current recommended tag - relying on the operator’s default silently changes what Postgres version and base image you’re running.
  3. Scope the R2 API token to exactly one bucket with Object Read & Write, not an account-wide Admin token - the credentials live in a Kubernetes Secret, and least privilege limits the blast radius if that Secret is ever exposed.
  4. Set retentionPolicy deliberately based on your actual RPO requirement, not by copying “3d” from this guide - a 3-day window is a genuine cost/recovery tradeoff, appropriate for some workloads and too short for others.
  5. Monitor WAL archiving status, not just backup completion - kubectl cnpg status surfaces this directly; a stalled WAL archiver silently degrades your point-in-time recovery window even while scheduled base backups keep succeeding.

Conclusion

CloudNativePG plus the Barman Cloud Plugin turns “Postgres with real backups” into a handful of Kubernetes manifests, and Cloudflare R2’s zero-egress pricing model plus a short retention window makes the storage side of that genuinely inexpensive for small production workloads - without giving up point-in-time recovery, replication, or automated failover along the way. The GitOps deployment of this same setup with ArgoCD covers how to manage it declaratively rather than applying these manifests by hand.

Additional Resources