How to Run Postgres on Kubernetes with CloudNativePG 1.30 (2026)
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.
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
How to Upgrade an EKS Cluster to 1.37 Safely: Step by Step (2026)
Upgrade Amazon EKS one minor version at a time without an outage: refresh upgrade insights, scan for removed APIs with Pluto, upgrade the control plane with eksctl or the AWS CLI, then VPC CNI, CoreDNS and kube-proxy, then node groups and Karpenter, with the 7-day rollback.
15 minOct 31, 2026How to Set Up Karpenter on EKS: NodePools, Spot and Consolidation (2026)
Step-by-step Karpenter 1.15.1 setup on an existing EKS cluster: CloudFormation IAM and SQS, EKS Pod Identity, Helm install, EC2NodeClass and NodePool YAML, Spot with on-demand fallback, Balanced consolidation and disruption budgets, Cluster Autoscaler migration, and fixes for nodes not launching.
14 minOct 31, 2026How to Back Up and Restore Kubernetes with Velero 1.18 (2026)
Step-by-step Velero 1.18.4 guide: install with Helm and the AWS plugin on S3 or MinIO, CSI snapshots with the data mover, file system backup, schedules, restore to a new namespace or cluster, and fixing PartiallyFailed.
13 min