Skip to content
Stribog

Kubernetes

All writing

PostgreSQL on Kubernetes with CloudNativePG: Building a Sovereign Stateful Data Layer Without RDS or Aurora

An engineering guide to running production PostgreSQL on Kubernetes with CloudNativePG: HA streaming replication, WAL archiving and PITR to your own object store, automated failover, and exit from managed-DB lock-in.

Stribog16 min read
sovereigntyoptionalityaudit

For most of the last decade, the consensus answer to "where does the database live?" was a managed service — RDS, Aurora, Cloud SQL — and the consensus answer to "can you run a stateful workload on Kubernetes?" was a nervous *probably not, and certainly not the database*. Both answers are now wrong. The managed-database premium has become a structural cost, the data-residency story underneath it has become a sovereignty liability, and — most importantly — the tooling for running PostgreSQL on Kubernetes has matured to the point where it is no longer the brave choice. It is the disciplined one.

The pivot is CloudNativePG, the operator that turns a PostgreSQL cluster into a first-class Kubernetes object. It joined the CNCF in January 2025, has moved through the Sandbox process toward Incubating, and reached the 1.29 release line in 2026. It manages the full lifecycle of a highly available Postgres cluster — primary election, streaming replication, continuous WAL archiving, point-in-time recovery, rolling minor upgrades, even declarative offline major-version upgrades — through native Kubernetes-native streaming replication rather than a bolt-on like Patroni. This article is the engineering case for using it to build a sovereign stateful data layer: one you own end to end, that recovers to any second in your retention window, that you can audit down to the WAL segment, and that you can walk away from a hyperscaler with.

This is not a tutorial that ends at kubectl apply. It is the decision framework, the durability model, the failover behavior, the storage trade-off that actually matters, and the honest accounting of where managed databases are still the right answer. The goal is the same one that runs through all of our work: own the systems you depend on, design the exit ramp before you need it, and make the data layer something an auditor can trust.

Why the Managed-Database Default Stopped Being the Safe Choice

The argument for RDS and Aurora was always operational: you trade money and control for not having to operate Postgres yourself. That trade was rational when the alternative was a hand-rolled primary with a cron-job pg_dump and a runbook nobody had tested. It is much less rational in 2026, for three reasons that compound.

The premium is now structural, not marginal. A managed database that costs a few thousand dollars a month at early scale costs a small engineering salary per month at maturity — and unlike compute, you cannot reserved-instance your way out of most of it, because a large fraction of the bill is the managed-service markup itself plus cross-AZ replication traffic and provisioned IOPS. The same logic that drives cloud repatriation more broadly applies with extra force to the data tier, because the data tier is where the markup is highest.

Aurora is not PostgreSQL, and that asymmetry is a lock-in mechanism. Aurora presents a Postgres-compatible interface over a proprietary distributed storage engine. The query planner has made different choices for years, the connection-handling and failover semantics differ, and the storage layer has no open-source equivalent. An application tuned against Aurora for three years has accumulated assumptions that do not hold against stock Postgres — which means the longer you stay, the more expensive the exit becomes. That is lock-in working exactly as designed, and it is the opposite of optionality.

Data residency is not sovereignty. A managed database in an EU region is still operated by a US-parent provider subject to the CLOUD Act, which is precisely the gap that the NIS2, DORA, and EU AI Act compliance wave has made legally material for regulated industries. For a regulated workload, "the data is in Frankfurt" and "we control who can compel access to the data" are different statements, and only the second one is sovereignty. Owning the Postgres cluster — the nodes, the storage, the backup target — closes that gap in a way no region selector can.

What CloudNativePG Actually Manages

CloudNativePG models a database cluster as a single custom resource. You declare intent — three instances, this much storage, synchronous replication, archive WAL here — and the operator's reconciliation loop drives the cluster to that state and keeps it there. There is no separate consensus sidecar (no Patroni, no etcd-for-Postgres); the operator uses the Kubernetes API itself as the source of truth and PostgreSQL's own native streaming replication for data movement. That architectural simplicity is the whole point — fewer moving parts means fewer failure modes to audit.

A minimal but production-shaped Cluster looks like this. The instances: 3 gives you one primary and two replicas; the storage class points at fast local storage (more on that trade-off below); the plugins section wires in continuous backup via the Barman Cloud Plugin, which since CloudNativePG 1.26 is the recommended path (the older in-tree barmanObjectStore field is deprecated).

yaml
# The object store you own — MinIO, Ceph RGW, or any S3-compatible target.
# This is your sovereignty boundary: backups and WAL never leave infrastructure you control.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: pg-backups
  namespace: data
spec:
  configuration:
    destinationPath: s3://pg-backups/
    endpointURL: https://minio.data.svc:9000
    s3Credentials:
      accessKeyId:
        name: minio-creds
        key: ACCESS_KEY_ID
      secretAccessKey:
        name: minio-creds
        key: ACCESS_SECRET_KEY
    wal:
      compression: gzip          # compress WAL before it leaves the node
      maxParallel: 4             # parallel WAL upload for high-write clusters
    data:
      compression: gzip
      jobs: 4                    # parallel base-backup streams
  # Retention is enforced by the recovery-window policy on the object store.
  retentionPolicy: "30d"
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: orders-db
  namespace: data
spec:
  instances: 3                    # 1 primary + 2 replicas
  imageName: ghcr.io/cloudnative-pg/postgresql:17.4

  # Resource requests/limits matter: Postgres is not cattle. Pin memory and CPU.
  resources:
    requests: { memory: "8Gi", cpu: "2" }
    limits:   { memory: "8Gi", cpu: "4" }

  postgresql:
    parameters:
      max_connections: "400"
      shared_buffers: "2GB"
      wal_compression: "on"
    synchronous:
      method: any               # quorum-based synchronous replication
      number: 1                 # at least 1 replica must confirm each commit

  storage:
    size: 500Gi
    storageClass: local-nvme     # fast local disk — see the storage trade-off section

  # Continuous backup + WAL archiving to the object store above.
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: pg-backups

  # Let the operator drive minor-version upgrades without manual intervention.
  primaryUpdateStrategy: unsupervised
A production-shaped CloudNativePG Cluster: three instances, quorum synchronous replication (at least one replica must confirm each commit), and continuous WAL archiving to an object store you own. The ObjectStore CR is the sovereignty boundary — your backups live where you decide.

From this single resource, the operator creates the PersistentVolumeClaims, bootstraps the primary, clones the replicas via pg_basebackup, configures streaming replication, and — critically — publishes three Kubernetes Services your applications connect through: orders-db-rw (always routes to the current primary, for writes), orders-db-ro (load-balances across replicas, for read-only traffic), and orders-db-r (any instance). Your application talks to a stable Service name; the operator handles which pod is actually primary. When failover happens, the -rw Service repoints and the application reconnects — it never needs to know the primary's identity.

A CloudNativePG cluster: one primary, two synchronous-capable replicas, connection pooling via a managed PgBouncer Pooler, and continuous WAL archiving to an object store you own. No managed-cloud control plane sits between you and your data.

Durability You Can Audit: WAL Archiving and Point-in-Time Recovery

High availability and durability are different properties, and conflating them is how organizations end up with three replicas of corrupted data. Replication protects you from a node failing. It does not protect you from an errant DELETE, a bad migration, or a ransomware event that propagates to every replica in milliseconds. Durability — the ability to recover to a known-good moment — comes from continuous backup, and CloudNativePG's model here is the strongest argument for the whole approach.

The mechanism is continuous physical backup plus WAL archiving. The operator takes periodic base backups (a full physical copy of the data directory) and, between them, ships every write-ahead-log segment to the object store you chose as it is generated. Because PostgreSQL's WAL is a complete, ordered record of every change, a base backup plus the subsequent WAL stream lets you replay the database forward to any point in time — any second — between the oldest base backup and now. This is online: the database stays up the entire time, no downtime to back up. The same WAL stream that feeds your replicas feeds your recovery, which means your durability guarantee is exercised continuously, not just when you run a backup job.

Scheduling a base backup is declarative. So is recovery — and that symmetry is what makes the durability claim auditable rather than aspirational.

yaml
# A scheduled base backup — runs via the plugin, lands in your object store.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: orders-db-nightly
  namespace: data
spec:
  schedule: "0 0 2 * * *"        # 02:00 every day (6-field cron, seconds first)
  backupOwnerReference: self
  cluster:
    name: orders-db
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io
---
# Point-in-time recovery: bootstrap a BRAND-NEW cluster from the archive,
# replayed to an exact timestamp. The source cluster is untouched.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: orders-db-pitr
  namespace: data
spec:
  instances: 3
  storage:
    size: 500Gi
    storageClass: local-nvme
  bootstrap:
    recovery:
      source: orders-db-archive
      recoveryTarget:
        # Recover to the instant *before* the bad migration ran.
        targetTime: "2026-06-23 14:21:00.00000+00"
  externalClusters:
    - name: orders-db-archive
      plugin:
        name: barman-cloud.cloudnative-pg.io
        parameters:
          barmanObjectName: pg-backups
          serverName: orders-db        # read the archive of the original cluster
Point-in-time recovery is a declarative bootstrap: the operator restores the base backup with barman-cloud-restore and replays WAL with barman-cloud-wal-restore up to recoveryTarget.targetTime. You recover into a new cluster, so the recovery itself is non-destructive and fully testable — which is exactly what an auditor wants to see rehearsed.

This is the audit-grade rigor argument made concrete. An auditor can ask: what is your recovery point objective, and prove it. The answer is not a policy document — it is the WAL archive interval (seconds), the retention window (30 days in the manifest above), and a recovery drill that runs on a cadence and is recorded. The same discipline we apply to secrets and supply-chain provenance applies to data durability: the control is structural and demonstrable, not a checkbox.

Failover: What the Operator Does When the Primary Dies

The reason teams feared stateful workloads on Kubernetes was failover. A stateless pod can be killed and rescheduled with no consequence; a primary database cannot, because promoting the wrong replica or splitting the brain loses committed data. CloudNativePG's failover behavior is deterministic and worth understanding precisely, because "the operator handles it" is not an acceptable answer for the system of record.

The sequence is driven by the readiness probe and the reconciliation loop. When the primary's readiness probe fails, the controller picks it up on the next reconcile and initiates failover in a defined order. First it marks the target primary as pending and forces the failed primary pod to shut down — this is the fencing step, and it is what prevents split-brain: the old primary is stopped so it cannot accept writes, and the replicas' WAL receivers disconnect from it. Only once the WAL receivers are stopped does a leader election run. The operator chooses the most-aligned replica (the one with the least replication lag, i.e. the most WAL applied) and promotes it. The -rw Service repoints to the new primary, and normal operations resume. When the old primary pod restarts, it detects it is no longer primary and rejoins the cluster as a replica.

The timing is governed by knobs you control. .spec.switchoverDelay bounds how long a graceful primary shutdown is allowed to take while it archives pending WAL before an immediate shutdown is forced. The readiness probes — including replica probes that can be configured against a maximum acceptable lag — determine when a replica is considered eligible to serve traffic or be promoted. In practice, a healthy cluster on fast storage completes an unplanned failover in tens of seconds, with zero committed-write loss when synchronous replication is configured (because a commit is not acknowledged to the client until a quorum replica has the WAL). That last clause is the whole game: synchronous replication is the difference between "we failed over" and "we failed over and lost the last four seconds of orders."

Automated failover in CloudNativePG: the operator detects the readiness-probe failure, fences the old primary (preventing split-brain), elects the most-aligned replica, promotes it, and repoints the read-write Service — typically tens of seconds, no human in the loop, and zero committed-write loss under synchronous replication.

There is a trade-off embedded in synchronous replication, and naming it is the honest thing to do. Quorum synchronous replication trades write latency and availability-under-partition for zero data loss. With synchronous.number: 1, every commit waits for at least one replica to confirm the WAL write before the client gets its acknowledgment. That adds a network round-trip to commit latency, and — more subtly — if you have three instances configured to require one synchronous confirmation and lose two of them, writes will block rather than risk acknowledging data that only exists on the primary. That is the correct behavior for a system of record (a fintech ledger should refuse a write it cannot durably confirm), and the wrong behavior for a high-throughput analytics sink that can tolerate losing the last few seconds on a hard failure. CloudNativePG lets you choose per cluster. Choosing deliberately — rather than accepting whatever RDS defaulted you to — is itself a sovereignty gain.

The Storage Trade-off That Actually Matters

Every other decision in this article is downstream of one: what storage sits under the Postgres data directory. This is where most self-hosted database projects either succeed quietly or fail loudly, and the marketing rarely engages with it. There are two viable architectures, and they encode opposite bets.

Option A — distributed network storage (Ceph RBD, or a cloud block CSI). Here each Postgres pod's volume is a network-replicated block device. The appeal is that the data survives a node loss independent of Postgres replication, and a pod can be rescheduled onto any node and reattach its volume. The cost is latency and operational weight: every write traverses the network and the storage layer's own replication before it is durable, and operating Ceph at production scale is a genuine discipline — CRUSH map design, dedicated storage nodes, and engineers who know its failure modes. You are now running two distributed stateful systems (Postgres and Ceph), each with its own consensus and its own failure surface.

Option B — local NVMe with pod anti-affinity. Here each Postgres pod writes to fast directly-attached NVMe on its node, and durability across node loss comes from PostgreSQL's own streaming replication plus the WAL archive — not from the storage layer. The appeal is performance (local NVMe is an order of magnitude lower latency than network block storage, which matters enormously for a write-heavy OLTP database) and operational simplicity (no second distributed storage system to run). The cost is that a node loss means that pod's data is gone and the replica is rebuilt from scratch via pg_basebackup — which is fine, because you provisioned replicas and an archive precisely for this. You pin the three instances to different nodes (and ideally different racks/AZs) with anti-affinity so a single failure never takes the quorum.

This is the trade-off that matters because it sets both your performance ceiling and your operational surface. Pick local NVMe and your bottleneck is the disk you bought and your recovery story is Postgres replication you already understand. Pick Ceph and you have bought flexibility you may not need at the price of a system you must now master. Neither is wrong, but choosing Ceph by default — because it sounds more enterprise — is how stateful-on-Kubernetes projects acquire complexity they never use and pages they did not budget for.

Connection Pooling and the Things That Bite at Scale

PostgreSQL allocates a backend process per connection, which means connection count is a resource you must manage, not an afterthought. In a Kubernetes environment with many application pods each holding a pool, raw connection counts climb fast and can exhaust the database. CloudNativePG addresses this with a first-class Pooler resource — a managed PgBouncer deployment that the operator owns and keeps in sync with the cluster's topology.

yaml
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
  name: orders-db-pooler-rw
  namespace: data
spec:
  cluster:
    name: orders-db
  instances: 3                    # HA pooler — survives a pooler pod loss
  type: rw                        # pool against the read-write (primary) endpoint
  pgbouncer:
    poolMode: transaction         # transaction pooling: many clients, few backends
    parameters:
      max_client_conn: "1000"
      default_pool_size: "25"     # 25 server-side connections back the 1000 clients
A managed PgBouncer Pooler in transaction mode: a thousand application-side connections are multiplexed onto twenty-five PostgreSQL backends. The operator keeps the pooler pointed at the current primary across failovers, so application connection strings never change.

Beyond pooling, a short list of things that bite teams new to self-operated Postgres, none of which the operator can decide for you. Major-version upgrades: CloudNativePG 1.29 supports declarative offline in-place major upgrades — you change the image to a newer major version and the operator runs pg_upgrade during a controlled shutdown — but offline means a maintenance window, so plan it like one. shared_buffers and memory: Postgres wants real memory; set resources.requests equal to limits for the database and size shared_buffers to roughly 25% of it, because an OOMKilled primary is an unnecessary failover. Long-running transactions and bloat: these are Postgres operational realities that no operator removes — you still need autovacuum tuning and monitoring. Observability: CloudNativePG exports Prometheus metrics natively; wire them into the same stack that watches the rest of your platform on day one, not after the first incident.

Should This Workload Run on Kubernetes? A Decision Framework

The honest position is that PostgreSQL on Kubernetes is the right answer for many stateful workloads and the wrong answer for some. Sovereignty is a goal, not a mandate to self-host everything regardless of readiness. Three gates decide it, and all three must pass.

Gate one — do you have a platform team that will operate it? CloudNativePG removes an enormous amount of the toil of running Postgres, but it does not remove the need for someone who understands PostgreSQL operations, storage, and the cluster they run on. A database is not fire-and-forget. If the honest answer is that no one will own it, a managed service is the correct choice today, and the work is to build the capability before you migrate — the same operating-model shift that determines whether any repatriation succeeds.

Gate two — can your storage meet the latency bar? A transactional database on slow network storage will perform badly no matter how good the operator is. If you have fast local NVMe (or well-operated Ceph), you pass. If your only option is high-latency shared storage, address that first — storage is the foundation the whole thing sits on.

Gate three — is the engine portable? This gate is about what you are running, not where. PostgreSQL is portable: pg_dump is a clean exit, the wire protocol is stable, the semantics are open. A workload that is genuinely on PostgreSQL (or MySQL) clears this gate. A workload built natively on DynamoDB, Spanner, Bigtable, or Cosmos DB does not — those have no open-source equivalent with the same access model, and moving them is an application-layer rewrite, not a database migration. For those, the work is to re-architect onto a portable engine first; only then does running it yourself become an option. This is the same classification logic that governs any cloud exit.

A decision flow for stateful workloads. PostgreSQL on Kubernetes is the right call when you have a platform team, a portable engine, and storage that meets the latency bar. It is the wrong call — for now — when any one of those is missing. The framework tells you what to fix, not just whether to proceed.

When all three gates pass, the upside is substantial and compounding. You drop the managed-database premium and the cross-AZ replication tax. You gain a recovery story you can rehearse and an auditor can verify. You hold your data inside a boundary you control, which closes the residency-versus-sovereignty gap that compliance regimes now scrutinize. And you keep the exit ramp open — because a CloudNativePG cluster is just PostgreSQL, you can move it to another Kubernetes cluster, to bare metal, or even back to a managed service if your situation changes. That last property is the one that matters most: the goal was never to be on Kubernetes for its own sake. The goal was to never again be in a position where leaving was too expensive to contemplate.

We didn't move the database to Kubernetes to save money, though we did. We moved it because the day we proved we could restore it to any second of the last month, in a namespace nobody had touched, was the day the database stopped being the thing we were afraid of.
Platform lead, regulated SaaS — anonymized

That is the sovereign stateful data layer in one sentence: not the cheapest database, but the one you understand completely, can recover provably, and can walk away from on your own terms. The tooling to build it is mature, open, and in front of you. The decision that remains is whether you will own your data layer, or keep renting it from someone whose interests diverge from yours the moment you try to leave.

§FAQ/Common questions

Frequently asked

Is running PostgreSQL on Kubernetes with CloudNativePG production-ready in 2026?

Yes. CloudNativePG manages the full lifecycle of a highly available PostgreSQL cluster using native streaming replication, joined the CNCF in January 2025, and reached the 1.29 release line in 2026 with declarative major-version upgrades, quorum synchronous replication, and a first-class backup plugin. It is used in production for systems of record across regulated industries. The remaining requirement is organizational, not technical: you need a platform team that understands PostgreSQL operations and storage. The operator removes the toil of running Postgres on Kubernetes; it does not remove the need to understand the database itself.

How does CloudNativePG handle failover, and can it lose data?

When the primary's readiness probe fails, the operator fences the old primary (forcing it to shut down so it cannot accept writes and replicas disconnect their WAL receivers), runs a leader election to pick the most-aligned replica, promotes it, and repoints the read-write Service. A healthy cluster on fast storage completes this in tens of seconds with no human involvement. Whether it can lose data depends on your replication setting: with quorum synchronous replication, a commit is not acknowledged to the client until at least one replica has confirmed the WAL write, so failover loses zero committed writes. With asynchronous replication, you trade that guarantee for lower commit latency and may lose the last few seconds on a hard primary failure. You choose per cluster.

How does WAL archiving and point-in-time recovery work, and where do the backups live?

CloudNativePG takes periodic base backups and continuously archives every write-ahead-log segment to an S3-compatible object store you control — MinIO, Ceph RGW, or any provider you choose. Because the WAL is a complete ordered record of every change, a base backup plus the subsequent WAL stream lets you recover the database to any second between the oldest base backup and now. Recovery is declarative: you bootstrap a new Cluster resource with a recoveryTarget timestamp, and the operator restores the base backup and replays WAL up to that instant. Because the backups live in infrastructure you own, the object store is your sovereignty boundary — your data and your recovery capability never depend on a provider you cannot audit.

Should I use local NVMe or Ceph for the storage under PostgreSQL?

For most teams building their first sovereign PostgreSQL layer, local NVMe with pod anti-affinity is the better choice for the database tier specifically. It gives you the low write latency a transactional database needs and avoids running a second distributed storage system (Ceph) with its own consensus and failure modes. Durability across node loss comes from PostgreSQL's own streaming replication and the WAL archive, not from the storage layer — which is what you provisioned replicas and backups for. Ceph is the right answer for workloads that genuinely need shared block storage, but the database is usually not one of them. Choosing Ceph by default, because it sounds more enterprise, is how stateful-on-Kubernetes projects acquire operational burden they never use.

Is moving off RDS or Aurora to CloudNativePG worth the effort?

It depends on the engine and your scale. Moving from RDS for PostgreSQL is relatively clean, because RDS runs stock-compatible PostgreSQL — pg_dump/restore or logical replication gets you across with manageable effort. Moving from Aurora is harder, because Aurora is a Postgres-compatible interface over a proprietary storage engine, and applications tuned against it for years have accumulated assumptions that may not hold against stock PostgreSQL; budget for query-plan review and re-tuning. The payoff is the elimination of the managed-database premium, a recovery story you can audit, data residency that is actually sovereignty, and an open exit ramp. The exit cost from Aurora rises every quarter you stay, so the calculus favors acting sooner rather than later — but only once you have the platform team to operate the result.

What does CloudNativePG not solve that I still have to handle?

The operator automates lifecycle, replication, failover, and backup orchestration, but several things remain your responsibility. PostgreSQL tuning — shared_buffers, autovacuum, connection limits — is still yours; size memory deliberately, because an OOMKilled primary is an unnecessary failover. Major-version upgrades are declarative but offline, so they require a planned maintenance window. The operator itself is a privileged controller and part of your attack surface, so patch it with the same discipline as any other critical component (recent releases fixed a critical metrics-exporter CVE). And you must actually rehearse recovery: because PITR restores into a new cluster, you can drill it on a schedule against a throwaway namespace — a backup you have never restored is a hypothesis, not a guarantee.

PostgreSQL on Kubernetes CloudNativePGCloudNativePG high availability streaming replicationWAL archiving PITR object store Kubernetesexit RDS Aurora self-hosted PostgreSQLsovereign stateful data layer Kubernetes 2026CloudNativePG failover backup recovery

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.