StormaticsStormatics

CloudNativePG: How to Run PostgreSQL on Kubernetes

How CloudNativePG’s operator model handles failover, security, and backups on Kubernetes, and how it compares to Patroni and pgpool-II.

Key Takeaways

  • CloudNativePG splits into two roles: an operator that manages the cluster’s lifecycle, and Postgres itself (the operand) running inside each pod
  • A properly available cluster needs three availability zones, not two, since two zones can’t break a tie in primary election if they lose contact with each other
  • Failover (primary fails unexpectedly) and switchover (a deliberate, planned promotion) are both handled automatically by default, with no manual scripts required
  • Security is enforced at the container layer by default: non-root user, read-only root filesystem, all Linux capabilities dropped, and ALTER SYSTEM disabled cluster-wide
  • The default backup method, barmanObjectStore, only takes full backups; Volume Snapshot backups are the option when incremental or differential backups are needed
  • Instance-level tuning, resource limits, custom config, and seed SQL, is done through the Cluster manifest instead of editing files by hand
  • Unlike Patroni (needs an external etcd/Consul/ZooKeeper store) or pgpool-II (a separate middleware layer), CloudNativePG relies entirely on Kubernetes’ own API server

Kubernetes was never really built with databases in mind. (if you’re still weighing whether Postgres belongs on Kubernetes at all, this decision guide is a good place to start). Pods are supposed to be disposable. If one dies, you just start another and move on. A database can’t work that way. It has data that has to survive a restart, a primary that has to be found correctly every single time, and replicas that need to stay in sync no matter what the cluster is doing around them.

That mismatch is exactly the problem CloudNativePG solves. It’s an operator built specifically to run Postgres on Kubernetes the right way, and once you spend time with it, the design choices start to make a lot of sense.

How the Operator and Operand Work Together

CloudNativePG splits cleanly into two roles. The operator is a single deployment whose only job is managing the lifecycle of your Postgres cluster. It deploys the cluster, scales it, backs it up, and handles upgrades. The operand is Postgres itself, running inside a pod, with every instance in the cluster pulling from the same image.

You don’t write failover scripts. You don’t build your own health checks. You write a Cluster manifest describing what you want: how many instances, how much storage, where backups should go. The operator then continuously checks that the real cluster matches it. (If you want to see this hands-on, here’s a step-by-step walkthrough of standing up your first cluster).

Inside each pod sits the Postgres Instance Manager. It runs the actual postmaster process and answers Kubernetes’ startup, liveness, and readiness probes. Startup and readiness both check Postgres itself using pg_isready. Liveness is different: it checks whether the Instance Manager process is still responding, not whether Postgres is accepting connections.

Why CloudNativePG Needs Three Availability Zones

If you take one thing away from working with CloudNativePG, it should be this: a properly available cluster needs three availability zones, not two. Nodes get deployed in multiples of three, one per zone, giving you a primary and two replicas spread across separate failure domains.

Two zones sounds like it should be enough, but it isn’t. If those two zones lose contact with each other, there’s no clean way to decide who becomes primary. Three removes that tie completely. This lines up with how major cloud providers already structure Kubernetes regions, typically across at least three zones.

How CloudNativePG Handles Failover

You do not configure failover directly. You just tell the operator the total number of Postgres instances you want, and CloudNativePG decides the roles on its own: one instance always becomes the primary, and every other instance becomes a replica. For example:

- instances: 3 → 1 primary + 2 replicas
- instances: 5 → 1 primary + 4 replicas

If the primary ever fails, the operator detects it and promotes the healthiest replica to primary automatically, with no failover scripts or manual replication setup required.

By default, replication between the primary and its replicas is asynchronous, meaning replicas apply changes shortly after the primary does rather than at the exact same instant. This is fine for most applications, but it does leave a small window where the very latest writes could be lost if the primary fails at just the wrong moment. If your application can’t tolerate that, you can turn on synchronous replication by adding `.spec.postgresql.synchronous` to the manifest. From there, the operator manages the underlying `synchronous_standby_names` setting for you. Editing that setting by hand is considered unsafe, since a mistake there can block writes to the whole database.

Failover and switchover are distinct events, though closely related. Failover occurs when the primary fails unexpectedly, requiring the operator to react without any prior planning. On the other hand, a switchover is initiated deliberately, typically as part of a rolling update, whether that’s a new Postgres image, a resource change, or an operator upgrade.

Updating the primary happens in two steps, controlled by two different settings. primaryUpdateStrategy decides when it happens. By default, the operator does this automatically, with no approval needed. primaryUpdateMethod decides how it happens, and by default, the operator just restarts the primary’s pod, so the same node stays primary. A switchover, where a replica gets promoted to take over instead, only happens if that restart fails, or if you have turned switchover mode on yourself.

Either way, there is a brief moment of downtime, usually small, but not zero. If you would rather trigger the primary’s update yourself instead of letting it happen automatically, set primaryUpdateStrategy: supervised, and the operator will wait for you to do it.

How CloudNativePG Handles Security

CloudNativePG does not rely on Kubernetes’ security alone. At the container layer specifically, least privilege is enforced through concrete settings in the pod’s security context, not stated as a general policy. Containers run as a non-root user. The root filesystem is mounted read-only.  `allowPrivilegeEscalation` is set to `false`. Every Linux capability is dropped, and none are added back. Postgres does not need them: it runs as its own user with the right file permissions already in place, and it uses port 5432, a regular, non-privileged port that doesn’t need any special access to open.

Operand images are also built from minimal base images to reduce the attack surface, and are rebuilt regularly to pick up upstream security patches. These restrictions serve a specific purpose: a process with no root access, no writable root filesystem, and no unnecessary capabilities has very little room to act even if the container running it is compromised, which is what keeps that compromise from spreading through the rest of the cluster.

On top of that, the operator manages its own Role-Based Access Control through a dedicated service account, provisions its own TLS certificates, and encrypts replication traffic between nodes automatically. 

Authentication still comes down to the same files any Postgres admin already knows, just declared through the manifest instead of edited by hand on disk:

  • pg_hba.conf decides who can connect and how, whether that’s password, certificate, or trust-based access.
  • pg_ident.conf maps an external OS identity to an internal Postgres role, and it only matters once pg_hba.conf is set up to use name mapping.
  • custom.conf is where custom settings actually live. The operator keeps it consistent across every node so nobody’s manually syncing config files across a cluster by hand.

An important point is that by default, ALTER SYSTEM does not work anywhere in a CloudNativePG cluster, not on the primary, and not on any replica. CloudNativePG enforces this by making the configuration file ALTER SYSTEM would normally write to read-only, so the command fails with an error instead of silently succeeding somewhere unsafe.

This default exists because of how ALTER SYSTEM behaves in Postgres. It modifies configuration only on the node where it is executed, and that change is not replicated to other nodes in the cluster. If permitted, running it on the primary would leave every replica unaware of the change, resulting in configuration that gradually diverges across nodes. Because the Cluster manifest is intended to be the single source of configuration for the cluster, disabling ALTER SYSTEM preserves that guarantee.

How CloudNativePG Handles Backup and Recovery

The default backup method is `barmanObjectStore`, meaning backups are pushed to object storage like S3, configured through `.spec.method`. It’s reliable, but every backup taken through this method is a full backup. There is no incremental or differential option available through `barmanObjectStore` specifically.

That limitation applies to this particular method, not to CloudNativePG as a whole. If incremental or differential backups are what you need, CloudNativePG supports Volume Snapshot backups as an alternative, which capture the backup at the storage layer instead of streaming a full copy to object storage. Whether those snapshots end up being incremental depends on the underlying storage class and CSI driver: most cloud block storage (Amazon EBS, Google Persistent Disk, Azure Disk, and others) supports differential snapshots natively, meaning only the blocks that changed since the previous snapshot are actually stored, even though CloudNativePG triggers the operation as a single backup.

How to Customize a PostgreSQL Instance

In a traditional Postgres setup, tuning an instance usually means SSHing into a node and editing configuration files by hand. CloudNativePG replaces that process entirely: instance-level customization is expressed through the Cluster manifest instead.

CPU and memory limits are set using the `resources` field, the same way any other Kubernetes workload defines its resource requests and limits. Projected volumes let additional files, such as custom configuration or TLS certificates, be mounted into a pod without modifying the container image itself.

Initialization can also be automated through three related settings, each targeting a different scope: `postInitSQL` runs a set of SQL statements once against the `postgres` database, immediately after the cluster is created; `postInitTemplateSQL` runs against the `template1` database, so its changes apply to every database created afterward; and `postInitApplicationSQL` runs against the specific application database defined for the cluster. Together, they let routine setup work, such as creating extensions, roles, or seed data, happen automatically instead of manually after every cluster is provisioned.

How CloudNativePG Differs From Patroni and pgpool

It’s worth placing CloudNativePG next to the tools that have traditionally handled Postgres high availability, since the comparison explains a lot of the design choices covered above. Patroni manages failover by running an agent alongside Postgres on every node, and those agents coordinate leader election through an external distributed consensus store, typically etcd, Consul, or ZooKeeper. That store has to be deployed and kept healthy in its own right, and a separate proxy layer such as HAProxy is usually added on top to route traffic to whichever node is currently primary.

pgpool-II takes a different approach: it’s an all-in-one middleware layer that sits in front of a Postgres cluster, bundling connection pooling, query load balancing, caching, and failover through its watchdog feature into a single tool, rather than being a Kubernetes-native operator that manages the underlying cluster’s lifecycle.

CloudNativePG does not require any of that external machinery. Kubernetes already provides what Patroni relies on etcd for: a consistent, distributed store of cluster state, accessed through the Kubernetes API server. So the operator uses Kubernetes’ own primitives instead of introducing a separate consensus system. Failover, connection routing, backups, TLS, and RBAC are all handled by the same operator reconciling the same Cluster manifest, rather than being spread across several independently deployed tools that each need their own configuration and monitoring.

Final Thoughts

None of these defaults are arbitrary. Three availability zones, least privilege at the container layer, configuration declared in the manifest instead of edited by hand, object storage as the default backup method, and a single operator handling what traditionally required several separately managed tools, none of it is arbitrary. Each one reflects a planned design decision about how a production Postgres cluster should behave on Kubernetes.

Once you are comfortable with the Cluster manifest, you stop writing scripts for failover, security, and backups. Set three instances, TLS-encrypted replication between nodes, and backups to S3 in the manifest once, and the operator keeps enforcing all three from then on.

Leave A Comment