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, kubectl and helm on your local machine.

A few commands below take your cluster ID. Export it once:

bash
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.
  • WaitForFirstConsumer binding. 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:

bash
kubectl get storageclass

The 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:

bash
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update cnpg

The operator chart also defaults to empty resource requests, so set them explicitly. Create cnpg-operator-values.yaml:

yaml
replicaCount: 1

resources:
  requests:
    cpu: 100m
    memory: 200Mi
  limits:
    memory: 200Mi

Note 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:

bash
helm upgrade --install cnpg cnpg/cloudnative-pg --version 0.29.0 \
  -n cnpg-system --create-namespace --values cnpg-operator-values.yaml

Confirm the operator is running and its custom resources are registered:

bash
kubectl get pods -n cnpg-system
kubectl get crd | grep cnpg

You 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:

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:

yaml
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required

enablePodAntiAffinity 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:

bash
kubectl create namespace demo
kubectl apply -f pg-cluster.yaml

Watch the cluster build itself:

bash
kubectl get cluster -n demo pg-demo -w

The 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:

bash
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-640389611

You can now see the anti-affinity rule the operator generated from those three fields:

bash
kubectl get pod -n demo pg-demo-1 -o jsonpath='{.spec.affinity.podAntiAffinity}' | jq
json
{
  "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:

bash
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       | async

The 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:

ServiceRoutes toUse for
pg-demo-rwthe current primarywrites and read-write transactions
pg-demo-roreplicas onlyread-only queries you want kept off the primary
pg-demo-rany instanceread-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:

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}]
bash
kubectl apply -f pg-smoke-test.yaml
kubectl logs -n demo job/pg-smoke-test
CREATE 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:

bash
kubectl get cluster -n demo pg-demo
kubectl delete pod -n demo pg-demo-1
kubectl get cluster -n demo pg-demo

The 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:

bash
kubectl get cluster -n demo pg-demo
NAME      INSTANCES   READY   STATUS                     PRIMARY
pg-demo   3           3       Cluster in healthy state   pg-demo-2

Verify the data survived:

bash
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:

bash
kubectl get pv -o jsonpath='{.items[0].spec.nodeAffinity}' | jq
json
{
  "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-demand

Note 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:

bash
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"]
  }
}
EOF

The 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:

yaml
  affinity:
    nodeSelector:
      cfke.io/provider: hetzner
      topology.kubernetes.io/region: nbg1
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required

This 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:

bash
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-640389611

Changing 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:

yaml
  affinity:
    nodeSelector:
      cfke.io/provider: hetzner
    enablePodAntiAffinity: true
    topologyKey: topology.kubernetes.io/zone
    podAntiAffinityType: required

This 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:

bash
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/zone

Reading 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     hel1

Two 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:

bash
kubectl delete -f pg-cluster.yaml

To tear down everything, delete the Cloudfleet cluster. CFKE deprovisions all fleet nodes with it:

bash
cloudfleet clusters delete $CLUSTER_ID

Next 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.

On this page