Skip to content

Run Postgres with CloudNativePG

Most real applications need Postgres, and running it well on Kubernetes used to be the argument against running it on Kubernetes at all. CloudNativePG (CNPG) changed that: a CNCF operator that manages PostgreSQL clusters declaratively, with provisioning, replication, failover and backups defined in one short manifest. This guide goes from nothing to a backed-up database your app can use. The steps work on any conformant cluster, a PaaSbox cluster among them.

  • Any Kubernetes cluster and its kubeconfig.
  • kubectl installed locally.
  • For the backup section: an S3-compatible bucket and its access keys, for example at Hetzner Object Storage, which bills your own Hetzner account.

On a PaaSbox cluster, check one thing first. If the cluster runs the PaaSbox Platform add-on (built in a lab, not published yet) with the profile saas-http01 or saas, CloudNativePG is installed already: skip step 1 and do not install the operator a second time. An app on the platform asks for its database in its SaaSApplication (data.postgres), with backups to S3; see Learn step 2, Deploy your first app.

1. Install the operator (once per cluster)

Section titled “1. Install the operator (once per cluster)”
Terminal window
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml

This installs the operator into cnpg-system (release 1.30 as of 2026-07-09; check CNPG releases for the current one). From here on, Postgres clusters are Kubernetes resources.

Save as pg.yaml. This is the configuration a small app starts with:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg
namespace: myapp
spec:
instances: 1
# A zero-eviction PDB would block node drains and protects nothing with a
# single replica. Re-enable (the default) when instances > 1.
enablePDB: false
bootstrap:
initdb:
database: myapp
owner: myapp
storage:
size: 2Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
Terminal window
kubectl create namespace myapp
kubectl apply -f pg.yaml
kubectl -n myapp wait --for=condition=Ready cluster/pg --timeout=300s

Where the 2 Gi volume lives depends on the cluster’s default storage class.

  • On a PaaSbox cluster the default class is local-lvm-thin: the volume is on the server’s own disk, at local disk speed and at no extra cost. It survives a restart, a reboot and an image update in place, not a replaced server.
  • For a volume that outlives the pods and the server, switch on the Hetzner volumes add-on (hcloud-csi, see Choose add-ons) and add storageClass: hcloud-volumes under storage. The volume is then a Hetzner volume in your project, at least 10 GB, on your Hetzner bill. Deleting the PaaSbox cluster deletes it too. On a cluster whose default class is Hetzner’s volume driver, a claim gets such a volume without naming the class.

CNPG generates everything your app needs. For a cluster named pg, you get Services pg-rw (primary, read-write), pg-ro (replicas, read-only), and a Secret pg-app containing ready-made credentials, including a uri key of the form postgresql://myapp:…@pg-rw.myapp:5432/myapp. Most frameworks consume that directly:

env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: pg-app
key: uri

No password handling, no manual Secret creation. The operator owns and rotates the credentials.

For production, change one number:

spec:
instances: 3

CNPG runs one primary and two streaming replicas, spreads them across nodes, and fails over automatically if the primary’s node dies. The pg-rw Service always points at the current primary, so your app does not change. Remove the enablePDB: false line (the default PodDisruptionBudget now protects you during node drains), and make sure the cluster has at least three nodes that can run them.

A PaaSbox cluster has one node. Three instances there would share one server: failover would cover a crashed Postgres process, not a lost server, and the memory would triple. On a single node, keep one instance and rely on the backups in step 5.

Volumes protect against pod restarts, not against DROP TABLE. CNPG does continuous WAL archiving plus scheduled base backups to object storage. Create a Secret with your bucket credentials, then add the backup section:

Terminal window
kubectl -n myapp create secret generic pg-backup-creds \
--from-literal=ACCESS_KEY_ID=<your-access-key> \
--from-literal=ACCESS_SECRET_KEY=<your-secret-key>
spec:
backup:
barmanObjectStore:
destinationPath: s3://my-backup-bucket/pg
endpointURL: https://nbg1.your-objectstorage.com # your S3-compatible endpoint
s3Credentials:
accessKeyId:
name: pg-backup-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: pg-backup-creds
key: ACCESS_SECRET_KEY
retentionPolicy: 30d

Then schedule a nightly base backup:

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: pg-nightly
namespace: myapp
spec:
cluster:
name: pg
schedule: "0 0 3 * * *" # 03:00 daily (six-field cron, seconds first)

With WAL archiving running continuously between base backups, you get point-in-time recovery: a new Cluster manifest with a bootstrap.recovery section restores to any moment inside your retention window. Rehearse it once before you need it.

A declarative Postgres with automated failover, credentials your app consumes from a Secret, and off-cluster backups with point-in-time recovery: the list that used to justify a managed database bill. What remains yours is the content: schema migrations, data quality, and testing the restore path. (A snapshot of a PaaSbox cluster holds the cluster’s state, its Kubernetes objects, not your database’s content. That is application data, and protecting it is your job. This setup is how.)

To see it inside a full application, the deploy a Django SaaS guide uses this exact CNPG pattern as its database layer.

Terminal window
kubectl delete namespace myapp # removes the cluster and its volumes (backups in the bucket remain)