Run PostgreSQL on Kubernetes with CloudNativePG
This guide walks you through running a highly available PostgreSQL cluster on Cloudfleet Kubernetes Engine (CFKE) using CloudNativePG, commonly shortened to CNPG, the CNCF operator for PostgreSQL. You will finish with a three-instance PostgreSQL 18 cluster on Hetzner Cloud block storage, with streaming replication, TLS-secured connections, controlled node placement, and automatic failover that completes in seconds.
Hetzner is a good place to run this. Managed PostgreSQL is priced per gigabyte of memory and storage, and those are exactly the two things a database needs most of, which is why a self-managed cluster on Hetzner Cloud servers costs a fraction of the equivalent managed instance. CNPG is what makes running it yourself reasonable rather than a second job.
CNPG gives you essentially everything a managed PostgreSQL service does: high availability with automatic failover, streaming replication, backups and point-in-time recovery to object storage, connection pooling, rolling minor version upgrades, and TLS with certificates it issues and rotates itself. The difference is what you keep. The same manifests run on any Kubernetes cluster, on any provider, in any datacenter, so the database travels with your workloads instead of anchoring them to one vendor’s control plane. If you later want to move from a hyperscaler to European infrastructure, or from cloud to your own hardware, the database moves the same way the rest of your workloads do.
That portability is also what makes CNPG a practical answer to data sovereignty requirements. You decide exactly which country and which datacenter your data sits in, you can prove it from the cluster itself, and changing that decision later is a scheduling change rather than a migration project. Managed database services rarely offer that, because the control plane belongs to the provider even when the data is stored in-region.
CloudNativePG is a particularly good fit for CFKE. It has no external dependencies, it manages the full lifecycle of a PostgreSQL cluster through a single custom resource, and it relies on ordinary Kubernetes primitives for scheduling and storage. That means CFKE’s just-in-time node provisioner handles the compute side automatically: you declare three PostgreSQL instances, and the nodes to run them are provisioned for you.
Prerequisites
- A running CFKE cluster with a fleet attached. See the getting started guide if you need one. This guide uses a Hetzner fleet named
hetzner-fleet. - The Cloudfleet CLI,
kubectlandhelmon your local machine.
A few commands below take your cluster ID. Export it once:
export CLUSTER_ID=<cluster-id>CloudNativePG itself is provider-agnostic, and so is most of this guide. Fleets also support AWS, using an IAM role ARN, and Google Cloud, using a project ID, and self-managed nodes connect any other cloud, on-premises, or edge infrastructure. If you are on one of those, swap two things: the storage class in step 1 and the cfke.io/provider value in every nodeSelector. The rest of the manifests work unchanged. The parts that are genuinely Hetzner-specific are called out where they appear, and the region section is the main one, because Hetzner volumes are bound to a single location.
Step 1: Set up persistent storage
CloudNativePG gives every instance its own PersistentVolumeClaim, so your cluster needs a storage class that can provision block volumes before you deploy a database. Three properties matter:
- ReadWriteOnce block storage. Each instance owns its volume, so no shared filesystem is needed.
WaitForFirstConsumerbinding. Volumes are created where the pod actually lands, which matters on CFKE because nodes are provisioned just in time.- Volume expansion, so you can grow a database later without recreating it.
Which driver provides this depends on your fleet. On Hetzner, install the Hetzner Cloud CSI driver by following the persistent volumes with Cloudfleet on Hetzner tutorial. It gives you a default hcloud-volumes storage class with all three properties, and it reuses the Hetzner token CFKE already stored when you created the fleet, so no second API token is needed. On self-managed nodes you can use the local-path-provisioner, keeping in mind that local storage ties each instance to one node. For other providers, install that provider’s CSI driver.
Whichever driver you choose, set resource requests in its chart values. CFKE sizes and provisions nodes from pod resource requests, and CSI charts commonly ship resources: {}. Install one unchanged and CFKE’s admission policy tells you so:
Warning: Validation failed for ValidatingAdmissionPolicy 'resource-requests-are-not-set':
Resource requests are not set on one or more containers in pod template.It is a warning, not a rejection: the pods still schedule and the driver still works. But requests are the only signal the auto-provisioner has for sizing nodes, so without them it works from bad information, and nodes can end up too small for what lands on them. Setting requests costs a few lines. This applies to every third-party chart you install on CFKE, including the CloudNativePG operator in the next step.
Confirm your storage class before continuing:
kubectl get storageclassThe rest of this guide uses hcloud-volumes. If you are on a different class, substitute the name in the manifests below. Two Hetzner specifics do carry through: volumes start at 10 GB, and each volume lives in a single Hetzner location, which is what the region section later builds on.
Step 2: Install the CloudNativePG operator
Add the CloudNativePG chart repository:
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update cnpgThe operator chart also defaults to empty resource requests, so set them explicitly. Create cnpg-operator-values.yaml:
replicaCount: 1
resources:
requests:
cpu: 100m
memory: 200Mi
limits:
memory: 200MiNote the memory limit with no CPU limit. That is the recommended shape for CFKE workloads: a memory limit protects the node from a runaway container, while a CPU limit only causes unnecessary CFS throttling.
Install the operator:
helm upgrade --install cnpg cnpg/cloudnative-pg --version 0.29.0 \
-n cnpg-system --create-namespace --values cnpg-operator-values.yamlConfirm the operator is running and its custom resources are registered:
kubectl get pods -n cnpg-system
kubectl get crd | grep cnpgYou should see clusters.postgresql.cnpg.io among the CRDs, along with resources for backups, poolers, databases, and publications.
Step 3: Deploy a PostgreSQL cluster
Create a namespace and a Cluster resource. Save this as pg-cluster.yaml:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-demo
namespace: demo
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18.1
primaryUpdateStrategy: unsupervised
bootstrap:
initdb:
database: appdb
owner: appuser
storage:
size: 10Gi
storageClass: hcloud-volumes
walStorage:
size: 10Gi
storageClass: hcloud-volumes
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 1Gi
affinity:
nodeSelector:
cfke.io/provider: hetzner
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: required
postgresql:
parameters:
max_connections: "200"Several choices here are worth calling out.
walStorage puts the write-ahead log on its own volume. This is the recommended production layout: WAL writes are sequential and constant, data file writes are random and bursty, and separating them keeps one from starving the other. It also means a filling WAL cannot consume the space your data needs.
Hetzner Cloud volumes start at 10 GB, so 10Gi is the smallest size worth asking for. Smaller requests are rejected.
The memory request equals the memory limit, and there is no CPU limit, following the same reasoning as the operator values above. CloudNativePG derives shared_buffers from the memory available to the pod, so this also determines how much PostgreSQL will cache.
nodeSelector: cfke.io/provider: hetzner only does work on a cluster that spans providers. A Hetzner Cloud volume can attach only to a Hetzner server, so on a mixed cluster the selector is what stops an instance from being scheduled somewhere its storage cannot follow. If your fleet uses a single provider, every node already matches and you can leave the selector out: CFKE will use whatever infrastructure it provisions. What matters in that case is that the storage class you name works on the nodes you have, which is the point of step 1.
Keeping instances on separate nodes
The affinity block is the part that makes this cluster genuinely highly available, so it deserves a closer look:
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: requiredenablePodAntiAffinity tells the operator to generate a pod anti-affinity rule across all instances of this PostgreSQL cluster. topologyKey: kubernetes.io/hostname makes the unit of separation a node. podAntiAffinityType: required makes it a hard constraint rather than a preference.
Without this, nothing stops the scheduler from packing all three instances onto one node, and a single node failure would take down the whole database along with every replica meant to survive it. CloudNativePG defaults enablePodAntiAffinity to true, but the default anti-affinity type is preferred, which the scheduler will happily violate when capacity is tight. On a database, make it required.
On CFKE this constraint also drives node provisioning. As each replica is created, the scheduler finds no eligible node, and CFKE provisions a new one to satisfy the rule. You will watch the cluster grow from one node to three as the replicas join, which is the behaviour you want: capacity follows the availability requirement instead of you sizing a node pool up front.
Apply it:
kubectl create namespace demo
kubectl apply -f pg-cluster.yamlWatch the cluster build itself:
kubectl get cluster -n demo pg-demo -wThe status moves through Setting up primary, then Creating a new replica once for each replica, and finally settles on Cluster in healthy state. Expect the whole sequence to take five to ten minutes on an empty cluster, most of it spent provisioning Hetzner nodes.
When it is done you have three pods on three separate nodes:
kubectl get pods -n demo -l cnpg.io/cluster=pg-demo \
-o custom-columns='POD:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName'POD STATUS NODE
pg-demo-1 Running model-husky-436990372
pg-demo-2 Running pumped-ibex-3909944675
pg-demo-3 Running cute-crab-640389611You can now see the anti-affinity rule the operator generated from those three fields:
kubectl get pod -n demo pg-demo-1 -o jsonpath='{.spec.affinity.podAntiAffinity}' | jq{
"requiredDuringSchedulingIgnoredDuringExecution": [
{
"labelSelector": {
"matchExpressions": [
{ "key": "cnpg.io/cluster", "operator": "In", "values": ["pg-demo"] },
{ "key": "cnpg.io/podRole", "operator": "In", "values": ["instance"] }
]
},
"topologyKey": "kubernetes.io/hostname"
}
]
}The rule selects on cnpg.io/podRole: instance, so it separates PostgreSQL instances from each other without interfering with the operator’s transient bootstrap and join jobs.
Confirm replication is streaming:
kubectl exec -n demo pg-demo-1 -c postgres -- \
psql -U postgres -x -c "SELECT application_name, state, sync_state FROM pg_stat_replication;"-[ RECORD 1 ]----+----------
application_name | pg-demo-2
state | streaming
sync_state | async
-[ RECORD 2 ]----+----------
application_name | pg-demo-3
state | streaming
sync_state | asyncThe operator also created two PodDisruptionBudgets for you, one that keeps at least one instance available during voluntary disruptions and one that protects the primary specifically. This is what makes node consolidation and cluster upgrades safe.
Step 4: Connect to the database
CloudNativePG creates three services in the namespace:
| Service | Routes to | Use for |
|---|---|---|
pg-demo-rw | the current primary | writes and read-write transactions |
pg-demo-ro | replicas only | read-only queries you want kept off the primary |
pg-demo-r | any instance | read-only queries where either is fine |
Applications should connect to pg-demo-rw. The service follows the primary automatically, so a failover does not require any application change.
Credentials for the application user live in the pg-demo-app secret, which contains ready-made uri, jdbc-uri, and pgpass entries alongside the individual fields. The cluster’s CA is in pg-demo-ca.
Here is a job that writes through the read-write endpoint and reads back through the read-only endpoint, with full TLS verification. Save it as pg-smoke-test.yaml:
apiVersion: batch/v1
kind: Job
metadata:
name: pg-smoke-test
namespace: demo
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
nodeSelector:
cfke.io/provider: hetzner
containers:
- name: psql
image: ghcr.io/cloudnative-pg/postgresql:18.1
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {memory: 64Mi}
env:
- name: PGHOST
value: pg-demo-rw
- name: PGDATABASE
value: appdb
- name: PGUSER
valueFrom: {secretKeyRef: {name: pg-demo-app, key: username}}
- name: PGPASSWORD
valueFrom: {secretKeyRef: {name: pg-demo-app, key: password}}
- name: PGSSLMODE
value: verify-full
- name: PGSSLROOTCERT
value: /etc/pg-ca/ca.crt
volumeMounts:
- name: ca
mountPath: /etc/pg-ca
readOnly: true
command:
- bash
- -c
- |
set -e
psql -c "CREATE TABLE IF NOT EXISTS demo (id serial primary key, note text, at timestamptz default now());"
psql -c "INSERT INTO demo (note) VALUES ('written via pg-demo-rw');"
psql -c "SELECT count(*) AS rows FROM demo;"
echo "--- read-only endpoint ---"
PGHOST=pg-demo-ro psql -c "SELECT pg_is_in_recovery() AS on_replica, count(*) FROM demo;"
volumes:
- name: ca
secret:
secretName: pg-demo-ca
items: [{key: ca.crt, path: ca.crt}]kubectl apply -f pg-smoke-test.yaml
kubectl logs -n demo job/pg-smoke-testCREATE TABLE
INSERT 0 1
rows
------
1
(1 row)
--- read-only endpoint ---
on_replica | count
------------+-------
t | 1
(1 row)The row written through the primary is immediately visible on a replica, and pg_is_in_recovery() returns true, confirming the read-only endpoint really did route to a standby. PGSSLMODE=verify-full means the client verified the server certificate against the cluster CA, so this is an encrypted and authenticated connection, not just an encrypted one.
Setting PGSSLMODE=verify-full is worth doing in your own applications too. CloudNativePG issues server certificates automatically, so the only cost is mounting the CA.
Step 5: Test failover
The point of three instances is surviving the loss of one. Delete the primary pod and watch what happens. The PRIMARY column shows which instance is serving writes:
kubectl get cluster -n demo pg-demo
kubectl delete pod -n demo pg-demo-1
kubectl get cluster -n demo pg-demoThe operator detects the loss, promotes the most advanced replica, and repoints the pg-demo-rw service at it. In this run the primary moved from pg-demo-1 to pg-demo-2 in six seconds. Applications connected to pg-demo-rw see a dropped connection and reconnect to the new primary.
The old instance is not discarded. CloudNativePG restarts it, reattaches its Hetzner volume, and rejoins it as a replica of the new primary:
kubectl get cluster -n demo pg-demoNAME INSTANCES READY STATUS PRIMARY
pg-demo 3 3 Cluster in healthy state pg-demo-2Verify the data survived:
kubectl exec -n demo pg-demo-2 -c postgres -- \
psql -U postgres -d appdb -c "SELECT count(*), max(at) FROM demo;"Controlling which region the database runs in
CFKE fleets are not region-scoped. A single Hetzner fleet can provision nodes in any Hetzner location, and by default the provisioner picks whichever is cheapest. For most workloads that is exactly right. For a database it usually is not, and the reason is storage.
Hetzner Cloud volumes are bound to a single location. The CSI driver reflects this by labelling nodes with csi.hetzner.cloud/location and stamping a matching node affinity onto every PersistentVolume it creates:
kubectl get pv -o jsonpath='{.items[0].spec.nodeAffinity}' | jq{
"required": {
"nodeSelectorTerms": [
{
"matchExpressions": [
{ "key": "csi.hetzner.cloud/location", "operator": "In", "values": ["nbg1"] }
]
}
]
}
}A PostgreSQL instance whose volume lives in nbg1 can therefore only ever be scheduled onto a node in nbg1. That is what you want, and it is why the failed instance in the previous step recovered cleanly. But it also means that if the provisioner scatters your instances across locations, each one is pinned to the location it landed in: replication traffic crosses the public network, and an instance can only ever recover where its volume already is.
CFKE labels every node so you can be explicit about this. A Hetzner node in Nuremberg carries:
cfke.io/provider: hetzner
cfke.io/region: europe
cfke.io/subregion: central
topology.kubernetes.io/region: nbg1
topology.kubernetes.io/zone: nbg1
csi.hetzner.cloud/location: nbg1
node.kubernetes.io/instance-type: cx23
karpenter.sh/capacity-type: on-demandNote that cfke.io/region is coarse, at continent level, while topology.kubernetes.io/region is the Hetzner location. Use the latter when you mean a specific datacenter.
There are three ways to take control, depending on whether the whole cluster should be regional, only the database should be, or the database should deliberately span locations.
Option A: lock the entire fleet to one location
If everything on this cluster should stay in one place, constrain the fleet itself. Fleet constraints move placement policy out of every pod spec and onto the fleet, where a missing selector cannot quietly put a workload in the wrong datacenter:
cloudfleet clusters fleets create $CLUSTER_ID -f - <<EOF
{
"id": "hetzner-fleet",
"hetzner": { "enabled": true, "apiKey": "<your-hetzner-api-token>" },
"limits": { "cpu": 16 },
"constraints": {
"topology.kubernetes.io/region": ["nbg1"],
"kubernetes.io/arch": ["amd64", "arm64"]
}
}
EOFThe provisioner will now only ever create nodes in nbg1, and no workload can accidentally end up elsewhere. Constraints compose, so you can pin architecture, instance family, and purchase type in the same block. This is the simplest option and a good default for a single-region product.
The architecture constraint above is worth setting deliberately. A fleet created without a constraints block defaults to kubernetes.io/arch: [amd64], which quietly excludes Hetzner’s arm64 CAX line, often the cheapest memory you can buy there and a good match for a database. CloudNativePG publishes multi-architecture images, so listing both lets the provisioner pick on price.
Two caveats when applying this to a fleet that already has workloads. Constraining an existing fleet can strand pods that no longer match, leaving them Pending because no fleet can satisfy them, so check what is currently scheduled first. And fleet updates are full replacements: cloudfleet clusters fleets update resets any field you leave out, so read the current fleet, edit it, and pipe it back rather than sending a partial document.
Option B: keep the cluster multi-region, pin the database
More often you want the opposite: stateless services spread across locations for latency and resilience, with the database deliberately kept together. Leave the fleet unconstrained and pin the database instead, using affinity.nodeSelector on the Cluster resource:
affinity:
nodeSelector:
cfke.io/provider: hetzner
topology.kubernetes.io/region: nbg1
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: requiredThis is the combination worth understanding: the nodeSelector keeps all three instances inside one Hetzner location, while the anti-affinity rule keeps them on three different nodes inside it. You get node-level redundancy and local replication, and the rest of the cluster remains free to schedule anywhere. Anything else that needs to sit next to the database, such as a PgBouncer pooler or a batch job, should carry the same nodeSelector.
The operator applies the selector to every instance pod it manages, so you can verify it took effect without inspecting each manifest:
kubectl get pods -n demo -l cnpg.io/cluster=pg-demo \
-o custom-columns='POD:.metadata.name,SELECTOR:.spec.nodeSelector,NODE:.spec.nodeName'POD SELECTOR NODE
pg-demo-1 map[cfke.io/provider:hetzner topology.kubernetes.io/region:nbg1] set-panther-1265738465
pg-demo-2 map[cfke.io/provider:hetzner topology.kubernetes.io/region:nbg1] pumped-ibex-3909944675
pg-demo-3 map[cfke.io/provider:hetzner topology.kubernetes.io/region:nbg1] cute-crab-640389611Changing affinity on a running cluster triggers a rolling restart, replicas first and the primary last, so it is safe to apply to an existing database. Instances whose volumes already live in the selected location stay where they are.
Option C: spread deliberately across locations
If you want the cluster to survive the loss of an entire Hetzner datacenter, separate the instances by location rather than by node. CloudNativePG exposes this through the same affinity block, by changing the anti-affinity topologyKey:
affinity:
nodeSelector:
cfke.io/provider: hetzner
enablePodAntiAffinity: true
topologyKey: topology.kubernetes.io/zone
podAntiAffinityType: requiredThis replaces the kubernetes.io/hostname key from step 3. You do not need both: if every instance is in a different Hetzner location, they are necessarily on different nodes.
Because the rule is required, an instance cannot be placed in a location that already holds one. Once the existing locations are used up, the next instance is unschedulable everywhere, and CFKE provisions a node in a location it has not used yet. On a three-instance cluster this produces one instance per location, each with its volume created alongside it:
kubectl get pods -n demo -l cnpg.io/cluster=pg-demo \
-o custom-columns='POD:.metadata.name,NODE:.spec.nodeName'
kubectl get nodes -L topology.kubernetes.io/zoneReading the two outputs together gives one instance per location:
POD NODE ZONE
pg-demo-1 bright-muskox-2248219584 fsn1
pg-demo-2 model-husky-436990372 nbg1
pg-demo-3 supreme-tahr-384143606 hel1Two limits are worth knowing before you rely on this.
You need at least as many locations as instances. Three instances need three Hetzner locations. Ask for more instances than there are available locations and the surplus stays Pending indefinitely, because no node can ever satisfy the rule.
It only works on a new cluster, or on instances that have not been created yet. A volume is bound to the location it was created in, so applying this to a running single-location cluster will not move anything. The existing instances stay where their data is.
The cost of spreading is latency on WAL shipping and cross-location traffic counted against your Hetzner server allowances. With CloudNativePG’s default asynchronous replication that latency is absorbed by the replicas rather than by your writes, so for most workloads this is a reasonable trade. If you also enable synchronous replication, measure it first.
Cleaning up
Deleting the CloudNativePG cluster removes its pods and PVCs, and the CSI driver deletes the underlying Hetzner volumes:
kubectl delete -f pg-cluster.yamlTo tear down everything, delete the Cloudfleet cluster. CFKE deprovisions all fleet nodes with it:
cloudfleet clusters delete $CLUSTER_IDNext steps
Backups and point-in-time recovery. CloudNativePG 1.26 and later handle object storage backups through the Barman Cloud plugin, which you install alongside the operator and configure with an ObjectStore resource. Any S3-compatible endpoint works, including Hetzner Object Storage. Follow the official backup documentation and the plugin repository for setup. Note that the plugin requires cert-manager in the cluster, which you can install by following the NGINX Ingress and cert-manager tutorial.
Connection pooling. PostgreSQL handles connections with one backend process each, which becomes expensive well before max_connections is reached. CloudNativePG’s Pooler resource deploys PgBouncer in front of your cluster as a managed resource.
Synchronous replication. The setup above uses asynchronous replication, which can lose recently committed transactions on failover. If your workload cannot tolerate that, configure synchronous replication so commits wait for a replica to acknowledge.
Monitoring. Every CloudNativePG instance exposes Prometheus metrics. Set monitoring.enablePodMonitor: true on the cluster once you have the Prometheus Operator installed, and import the project’s Grafana dashboard.
Exposing PostgreSQL outside the cluster. If you need external access, front the -rw service with a LoadBalancer service and set externalTrafficPolicy: Local. On CFKE the default Cluster policy provisions a load balancer in every location where nodes exist, which costs more and routes less efficiently. See the Cloudfleet load balancing documentation.
For the full range of what the operator can do, see the CloudNativePG documentation.