How to Run Postgres on Kubernetes with CloudNativePG 1.30 (2026)

Oct 31, 2026 · 15 min · Ajay Kumar

This guide is for platform and backend engineers who want a production-shaped PostgreSQL cluster on Kubernetes rather than a single pod with a volume. By the end you will have a three-instance PostgreSQL 18 cluster managed by CloudNativePG, continuous WAL archiving and daily base backups in S3, a rehearsed point-in-time restore, a PgBouncer pooler, Prometheus scraping and alerts, and a plan for major upgrades. Versions are current as of 31 October 2026: CloudNativePG 1.30.1 (23 September 2026), the Barman Cloud plugin 0.15.1 (30 September 2026), cert-manager 1.21.3 and the 18.6-minimal-trixie PostgreSQL image, and every manifest below validates against the 1.30.1 and 0.15.1 CRD schemas. You need a Kubernetes 1.34, 1.35 or 1.36 cluster (the range CloudNativePG 1.30 supports), a default StorageClass that allows volume expansion, an S3 bucket, and kubectl with cluster-admin rights. If you are still deciding whether to do this at all, read the companion post on running databases on Kubernetes first.

What this guide builds: traffic, replication, backups and restore (namespace db) Application pods pg-main-app secret Pooler: PgBouncer x2 pg-main-pooler-rw Service pg-main-rw always the primary pg-main-1 (primary) PGDATA 50Gi + WAL 10Gi S3 ObjectStore s3://my-pg-backups/cnpg/ barman-cloud sidecar WAL archive + base backups pg-main-2, pg-main-3 standbys, quorum sync (any 1) WAL stream archives WAL cnpg-system operator + plugin pg-restore (PITR) recoveryTarget.targetTime restore ScheduledBackup daily 03:00, method plugin triggers PodMonitor + Prometheus metrics on :9187 and :9127 scraped Names match the manifests below. Sage: Postgres instances. Terracotta: the backup and restore path.
The resources this guide creates and how data flows between them. Names, ports and versions come from the manifests below and the CloudNativePG 1.30 documentation.

Step 1: Install cert-manager and the cnpg kubectl plugin

The Barman Cloud plugin talks to the operator over mutual TLS and its installation docs require cert-manager to issue those certificates. If cert-manager already runs in your cluster, skip the first command; if you need it for public certificates too, my cert-manager guide covers issuers.

# cert-manager 1.21.3 (released 23 Oct 2026)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.3/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager-webhook

# The cnpg plugin for kubectl: status, psql, backup, fio and pgbench helpers
brew install kubectl-cnpg          # or: kubectl krew install cnpg
kubectl cnpg version

Step 2: Install the CloudNativePG 1.30.1 operator

Apply the operator manifest server-side: the Cluster and Pooler CRDs are larger than the 256 KiB annotation that client-side apply would try to write.

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

kubectl rollout status deployment -n cnpg-system cnpg-controller-manager
# deployment "cnpg-controller-manager" successfully rolled out

If you prefer Helm, chart 0.29.1 ships operator 1.30.1. The deployment is then called cnpg-cloudnative-pg instead of cnpg-controller-manager, which matters for the log commands later.

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm upgrade --install cnpg cnpg/cloudnative-pg \
  --namespace cnpg-system --create-namespace \
  --version 0.29.1

On GKE private clusters, the control plane cannot reach the operator's webhook on port 9443 until you add a firewall rule; the installation docs call this out.

Step 3: Install the Barman Cloud plugin

Since CloudNativePG 1.26, backups to object storage are handled by a CNPG-I plugin instead of code inside the operator, and the old in-tree barmanObjectStore support is on its way out: the 1.30.0 release notes said 1.31.0, and the supported releases page now says 1.31 is the last release with it and 1.32.0 removes it. Start new clusters on the plugin. It must live in the same namespace as the operator.

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

kubectl -n cnpg-system rollout status deploy/barman-cloud
# deployment "barman-cloud" successfully rolled out

With Helm the equivalent is helm upgrade --install plugin-barman-cloud --namespace cnpg-system cnpg/plugin-barman-cloud --version 0.8.1, and the deployment is named plugin-barman-cloud.

Step 4: Create the S3 credentials and ObjectStore

Create a namespace and a Secret with keys the plugin can read. The backup identity needs to list the bucket and get, put and delete objects under the prefix; delete is what lets the retention policy clean up old backups.

kubectl create namespace db

kubectl create secret generic pg-backup-s3 -n db \
  --from-literal=ACCESS_KEY_ID='<access-key-id>' \
  --from-literal=ACCESS_SECRET_KEY='<secret-access-key>' \
  --from-literal=ACCESS_REGION='eu-west-1'

The ObjectStore describes where backups go. It is namespaced, so it must sit next to the Cluster that uses it.

# objectstore.yaml (barmancloud.cnpg.io/v1, plugin 0.15.1)
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: pg-main-s3
  namespace: db
spec:
  # Delete base backups (and the WAL they need) older than the window
  retentionPolicy: "30d"
  configuration:
    destinationPath: s3://my-pg-backups/cnpg/
    s3Credentials:
      accessKeyId:
        name: pg-backup-s3
        key: ACCESS_KEY_ID
      secretAccessKey:
        name: pg-backup-s3
        key: ACCESS_SECRET_KEY
      region:
        name: pg-backup-s3
        key: ACCESS_REGION
    wal:
      compression: gzip
      maxParallel: 4      # archive/restore up to 4 WAL files at once
    data:
      compression: gzip
      jobs: 2             # parallel upload streams for base backups
kubectl apply -f objectstore.yaml

On EKS, skip static keys: set s3Credentials.inheritFromIAMRole: true and annotate the cluster's service account through spec.serviceAccountTemplate.metadata.annotations with eks.amazonaws.com/role-arn. My workload identity explainer covers how that federation works. The plugin docs also recommend bucket versioning, Object Lock and lifecycle rules that expire objects a few days after the retention window, so a compromised credential cannot quietly delete your history.

Step 5: Create a three-instance Postgres cluster

This is the core manifest. Each field is commented with why it is there.

# cluster.yaml (postgresql.cnpg.io/v1, CloudNativePG 1.30.1)
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg-main
  namespace: db
spec:
  instances: 3                       # 1 primary + 2 streaming replicas
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie
  primaryUpdateStrategy: unsupervised
  primaryUpdateMethod: switchover    # promote a replica instead of restarting the primary

  postgresql:
    parameters:
      shared_buffers: "1GB"
      max_connections: "200"
    synchronous:                     # quorum sync replication to 1 standby
      method: any
      number: 1
      dataDurability: preferred      # degrade to async if no standby is healthy

  bootstrap:
    initdb:
      database: app
      owner: app

  storage:
    storageClass: gp3                # replace with your StorageClass
    size: 50Gi
  walStorage:                        # separate volume so WAL can't fill PGDATA
    storageClass: gp3
    size: 10Gi

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

  affinity:
    enablePodAntiAffinity: true
    topologyKey: topology.kubernetes.io/zone
    podAntiAffinityType: preferred   # use "required" once you have 3 zones

  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: pg-main-s3
kubectl apply -f cluster.yaml
kubectl get cluster -n db -w

Expected output once all three instances have joined:

NAME      AGE   INSTANCES   READY   STATUS                     PRIMARY
pg-main   4m    3           3       Cluster in healthy state   pg-main-1

Two choices deserve a note. dataDurability: preferred keeps writes flowing if both standbys are down, at the cost of a window where a commit lives on one node only; set required if you would rather block writes than risk that. Memory request equals limit so the pod never exceeds what the scheduler reserved, which my requests and limits explainer explains in more depth. If pods stay Pending, the Pending troubleshooting guide walks through the usual PVC and affinity causes.

Step 6: Connect and check replication

CloudNativePG creates three Services (pg-main-rw for the primary, pg-main-ro for standbys, pg-main-r for any instance) and a pg-main-app Secret with the app user's credentials and a ready-made connection URI.

kubectl cnpg status pg-main -n db          # replication, WAL, backup and plugin status

kubectl get secret pg-main-app -n db \
  -o jsonpath='{.data.uri}' | base64 -d; echo
# postgresql://app:<password>@pg-main-rw.db:5432/app

kubectl cnpg psql pg-main -n db -- -qAt -c 'SELECT version()'
# PostgreSQL 18.6 ...

In the status output, look for Cluster in healthy state, two standbys in streaming state listed as quorum (synchronous) standbys, and barman-cloud.cloudnative-pg.io listed under the plugins section. Applications should always connect through a Service, never to a pod name, because the primary moves during switchovers.

Step 7: Schedule backups and take one now

WAL archiving started as soon as the cluster came up. A base backup gives recovery a starting point. Note the six-field cron syntax: seconds come first.

# scheduledbackup.yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: pg-main-daily
  namespace: db
spec:
  schedule: "0 0 3 * * *"            # six fields, seconds first: 03:00:00 every day
  immediate: true                    # take the first backup now
  backupOwnerReference: self
  cluster:
    name: pg-main
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io
kubectl apply -f scheduledbackup.yaml

# An extra on-demand backup, for example before a risky migration
kubectl cnpg backup pg-main -n db \
  --method=plugin --plugin-name=barman-cloud.cloudnative-pg.io

kubectl get backups -n db
# NAME                           AGE   CLUSTER   METHOD   PHASE       ERROR
# pg-main-daily-20261024094012   3m    pg-main   plugin   completed

# Recovery window per server, as reported by the plugin
kubectl get objectstore pg-main-s3 -n db -o jsonpath='{.status.serverRecoveryWindow}'; echo

The CloudNativePG docs say a weekly base backup is enough in most cases; daily shortens restore time because less WAL has to be replayed. Pick based on your recovery time objective, not habit.

Step 8: Test a point-in-time restore

A backup you have never restored is a hypothesis. This drill creates data, records a timestamp, simulates an accident, then restores a new cluster to just before it.

kubectl cnpg psql pg-main -n db -- app -c \
  "CREATE TABLE orders (id int PRIMARY KEY, note text);
   INSERT INTO orders SELECT g, 'row ' || g FROM generate_series(1, 1000) g;"

date -u +%Y-%m-%dT%H:%M:%SZ        # note this, e.g. 2026-10-24T09:41:00Z
sleep 5
kubectl cnpg psql pg-main -n db -- app -c "DROP TABLE orders;"

# Close the current WAL segment so the DROP reaches S3 now, not in 5 minutes
kubectl cnpg psql pg-main -n db -- -c "SELECT pg_switch_wal();"

Now restore into a separate cluster. The externalClusters entry reads the source's folder; the plugins entry archives the new cluster under its own serverName so the two never write to the same path.

# restore.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg-restore
  namespace: db
spec:
  instances: 1                       # restore small, scale up after checking the data
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie
  storage:
    storageClass: gp3
    size: 50Gi
  walStorage:
    storageClass: gp3
    size: 10Gi

  bootstrap:
    recovery:
      source: origin
      recoveryTarget:
        targetTime: "2026-10-24T09:41:00Z"   # always give an explicit timezone

  externalClusters:
    - name: origin
      plugin:
        name: barman-cloud.cloudnative-pg.io
        parameters:
          barmanObjectName: pg-main-s3
          serverName: pg-main        # the folder written by the source cluster

  plugins:                           # archive the new timeline under a NEW serverName
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: pg-main-s3
        serverName: pg-restore
kubectl apply -f restore.yaml
kubectl get pods -n db -l cnpg.io/cluster=pg-restore -w   # a recovery Job runs first

kubectl cnpg psql pg-restore -n db -- app -qAt -c "SELECT count(*) FROM orders;"
# 1000

With targetTime set, the operator picks the closest base backup completed before the target and replays WAL up to it. Recovery stops at the first transaction after the target; if there is none, it fails, which is why the drill includes the DROP. Time the whole run and write the number down: that is your real recovery time, and it belongs in your disaster recovery plan. Delete the pg-restore Cluster when you are done.

Step 9: Add PgBouncer with a Pooler

Postgres pays for every connection with a backend process. A Pooler runs PgBouncer as a separate Deployment in front of the rw Service.

# pooler.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
  name: pg-main-pooler-rw            # must not equal a Cluster name
  namespace: db
spec:
  cluster:
    name: pg-main
  instances: 2
  type: rw                           # points at the pg-main-rw service (the primary)
  pgbouncer:
    poolMode: transaction
    parameters:
      max_client_conn: "1000"
      default_pool_size: "20"
      max_prepared_statements: "200" # protocol-level prepared statements in transaction mode
  template:
    spec:
      containers: []                 # required by PodSpec even when empty
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector:
                  matchLabels:
                    cnpg.io/poolerName: pg-main-pooler-rw
kubectl apply -f pooler.yaml
kubectl get pooler -n db
# NAME                AGE   CLUSTER   TYPE   PHASE
# pg-main-pooler-rw   1m    pg-main   rw     active

# Test from your laptop through the pooler Service (port 5432)
kubectl port-forward -n db svc/pg-main-pooler-rw 6432:5432 &
PGPASSWORD=$(kubectl get secret pg-main-app -n db -o jsonpath='{.data.password}' | base64 -d) \
  psql "host=127.0.0.1 port=6432 dbname=app user=app" -c 'SELECT 1'

Point applications at pg-main-pooler-rw.db.svc instead of pg-main-rw. Transaction pooling breaks session state (SET, advisory locks held across transactions, LISTEN), so use poolMode: session for apps that rely on it.

Step 10: Monitor with Prometheus

Every instance exposes metrics on port 9187 and every PgBouncer pod on 9127, both on a port named metrics. The enablePodMonitor field is deprecated, so create PodMonitors yourself. These assume the Prometheus Operator, for example from kube-prometheus-stack.

# podmonitors.yaml
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: pg-main
  namespace: db
spec:
  selector:
    matchLabels:
      cnpg.io/cluster: pg-main
  podMetricsEndpoints:
    - port: metrics                  # 9187 on every instance pod
---
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: pg-main-pooler-rw
  namespace: db
spec:
  selector:
    matchLabels:
      cnpg.io/poolerName: pg-main-pooler-rw
  podMetricsEndpoints:
    - port: metrics                  # 9127 on every PgBouncer pod

Load the project's sample PrometheusRule, which covers replication lag, failed archiving and transaction ID age, and add one rule it lacks: an alert when the last good backup is too old. The plugin exports barman_cloud_cloudnative_pg_io_last_available_backup_timestamp for exactly this.

# alert-backup.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: pg-main-backups
  namespace: db
spec:
  groups:
    - name: cnpg-backups
      rules:
        - alert: CNPGBackupTooOld
          expr: time() - max by (namespace) (barman_cloud_cloudnative_pg_io_last_available_backup_timestamp{namespace="db", pod=~"pg-main-[0-9]+"}) > 26 * 3600
          for: 15m
          labels:
            severity: critical
          annotations:
            summary: No successful base backup for pg-main in 26 hours

The official Grafana dashboard lives in cloudnative-pg/grafana-dashboards. Route these alerts sensibly; my Alertmanager guide covers grouping and inhibition.

Step 11: Upgrade PostgreSQL, minor and major

Minor versions (18.6 to the next 18.x) are an image change. Edit imageName and apply; the operator updates replicas one at a time, then switches over to an updated replica because of primaryUpdateMethod: switchover.

Major versions use offline in-place pg_upgrade, documented in PostgreSQL upgrades. Changing the major in imageName makes the operator stop every pod, run a Job named after the primary with a -major-upgrade suffix, start the primary, then delete and re-clone the replicas. Plan for the whole cluster to be down for the duration. For a cluster created on 17, the change looks like this:

# upgrade17.yaml: only the changed fields matter
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg-legacy
  namespace: db
spec:
  instances: 3
  # was: ghcr.io/cloudnative-pg/postgresql:17.10-minimal-trixie
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie
  storage:
    size: 50Gi
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: pg-main-s3
        serverName: pg-legacy-pg18   # was: pg-legacy (keeps PG17 backups intact)
kubectl cnpg backup pg-legacy -n db --method=plugin --plugin-name=barman-cloud.cloudnative-pg.io
kubectl apply -f upgrade17.yaml
kubectl get jobs -n db -w                        # pg-legacy-1-major-upgrade
kubectl cnpg psql pg-legacy -n db -- app -c 'ANALYZE'   # pg_upgrade does not carry planner statistics

Change serverName in the same edit, because pg_upgrade creates a new system ID and resets the timeline, and point-in-time recovery cannot cross the major version boundary. Take a fresh base backup right after. Keep the OS suffix the same (trixie to trixie); the docs say upgrades between distributions are not supported. PostgreSQL 19 is planned for GA on 19 November 2026; CloudNativePG 1.30 supports 14 to 18, so wait for a release that lists 19.

Upgrading the operator itself is a new kubectl apply --server-side of the next release manifest. By default that triggers a rolling update of every cluster it manages, so schedule it like any other change.

Troubleshooting

Symptom Likely cause Fix
Instance pods stay Pending No default StorageClass, or podAntiAffinityType: required with fewer zones than instances kubectl get pvc -n db; set storage.storageClass; use preferred until you have three zones
failed calling webhook "mcluster.cnpg.io" on apply API server cannot reach the webhook on port 9443 (GKE private clusters) Allow control plane to node traffic on 9443
requested plugin is not available: barman-cloud.cloudnative-pg.io Plugin missing from the operator's namespace, or not Ready Install it in cnpg-system; check the plugins section of kubectl cnpg status
Plugin pod in CrashLoopBackOff cert-manager absent or its certificates not issued kubectl get certificates -n cnpg-system; install cert-manager first
Backup phase failed with AccessDenied or 403 Secret keys do not match the ObjectStore references, or IAM lacks list, put or delete Compare key names; check the bucket policy; read kubectl logs pg-main-1 -c plugin-barman-cloud -n db
WAL volume filling up Archiving is failing, so Postgres keeps every segment Fix the object store error in the sidecar logs; watch the LastFailedArchiveTime alert
Restore stuck in Setting up primary, log shows Expected empty archive The restored cluster is archiving into the source's serverName Give the restore its own serverName parameter
PITR fails or stops short of the target Target is after the last archived WAL, no transaction follows it, or the timestamp has no zone Run pg_switch_wal(), check serverRecoveryWindow, use a Z suffix
Major upgrade Job fails Image distribution changed, an extension is incompatible, or Postgres 17.0 to 17.5 with max_slot_wal_keep_size set Revert imageName; the operator deletes the failed Job and restarts on the old version
prepared statement does not exist through the pooler Transaction pooling without prepared statement support Set max_prepared_statements, or use poolMode: session

For anything else, kubectl cnpg report cluster pg-main -n db --logs bundles the cluster spec, pods, jobs, events and pod logs into a zip you can attach to an issue.

FAQ

Does CloudNativePG still support barmanObjectStore backups?

It works in 1.30 but is deprecated. CloudNativePG 1.31 is the last release that ships it, and 1.32.0 removes it. Migrate by creating an ObjectStore with the same configuration and replacing spec.backup.barmanObjectStore with a plugins entry. The plugin repository has a migration page.

How many instances should a production CloudNativePG cluster have?

Three: one primary and two standbys spread across zones, which lets quorum synchronous replication with number: 1 survive one standby failing. Two instances work but leave no spare during a node drain. One instance is fine for development with backups enabled.

Can I use volume snapshots instead of S3 backups?

Yes, CloudNativePG supports VolumeSnapshot backups and recovery, which are much faster for large databases. You still need a WAL archive in object storage for point-in-time recovery, and snapshots usually live in the same region and account as the cluster, so keep an object store copy elsewhere.

Does PgBouncer run on the same pods as Postgres?

No. A Pooler is its own Deployment with its own Service, and its lifecycle is independent of the Cluster: deleting one does not delete the other.

In production the first thing I check on any CloudNativePG cluster is not replication, it is the backup alert and the date of the last restore drill, because a healthy-looking cluster with a broken WAL archive is the failure that hurts most. After that, the operator version against the supported releases table, since a cluster two minors behind misses both security fixes and failover fixes. The official references are the CloudNativePG documentation, the Barman Cloud plugin documentation and the release notes.

Related: How to Use External Secrets Operator with Vault on Kubernetes (2026), How to Implement Multi-Tenancy with Postgres Row-Level Security (2026) and How to Tune pgvector Performance: HNSW, ef_search and Quantization (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related