Skip to main content

CloudNativePG Restored 4.5TB of Postgres in Two Minutes: What Snapshot Recovery Really Buys Your Self-Hosted DR Promise

10 min readDora NodaDora Noda
Share
On this page

Two minutes. That is how long it took CloudNativePG to fully recover a roughly 4.5TB Postgres database from a Kubernetes volume snapshot in the project's own EKS benchmarks — all databases in the test restarted within two minutes, including the largest instance at roughly 4.4TB. If you run tenant Postgres on infrastructure you own, that number should rearrange your disaster-recovery planning. Restoring terabytes from an object-store backup is historically measured in hours, not minutes: AWS itself frames traditional point-in-time restore as an operation that "can take hours for large databases."

But the number comes with conditions that decide whether it applies to your fleet at all. Snapshot recovery is fast because the storage layer does the heavy lifting — which means your CSI driver has to support snapshots (Hetzner's does not), and snapshots do not leave the datacenter, so your object-store backups stay regardless. This post gives you the honest version: what the 4.5TB-in-two-minutes result actually proves, the mechanism behind it, a three-way RTO/RPO comparison with the sensitivity variables that move each number, and the runbook for turning it into a DR promise you can keep.

How snapshot recovery actually works

CloudNativePG 1.21 added declarative support for Kubernetes' standard VolumeSnapshot API, making it one of the first database operators to use native volume snapshots for both backup and recovery. The mechanism has two halves, and understanding both is what keeps you from misreading the benchmark.

The first half is the base backup. Instead of streaming every byte of a multi-terabyte database into an object store, you snapshot the volumes underneath it. An on-demand snapshot backup is one command against a standby, so production never feels it:

bash
kubectl cnpg backup -m volumeSnapshot hendrix

That creates a VolumeSnapshot per cluster volume (PGDATA, plus WALs if you keep them on a separate volume), and the CSI external-snapshotter turns each into a VolumeSnapshotContent — typically a matter of seconds to a couple of minutes, since the storage array records deltas rather than copying bytes. Scheduled snapshots work the same declarative way through a ScheduledBackup with method: volumeSnapshot.

The second half is recovery, and it is deliberately shaped like every other CloudNativePG recovery: you bootstrap a new cluster, except the new PVCs are provisioned from the snapshot set instead of replaying an object-store backup:

yaml
bootstrap:
  recovery:
    volumeSnapshots:
      storage:
        name: hendrix-20231017150434
        kind: VolumeSnapshot
        apiGroup: snapshot.storage.k8s.io

Once the PVCs exist, Postgres starts. The two-minute figure is essentially "provision volumes from snapshot deltas, then start Postgres" — there is no multi-terabyte download in the path. The author is explicit that this is an optimistic read: actual time depends on how the CSI snapshotter stores and materializes deltas. But the structural win holds across drivers: restore cost tracks changed blocks, not total database size.

The half people miss is the WAL tail. A snapshot alone restores you to the snapshot instant — a cold backup. If you want point-in-time recovery to any moment after that, you pair the snapshot base with continuous WAL archiving to an object store via the barman plugin, and replay WAL on top of the snapshot-restored base. Snapshots buy RTO; WAL archiving still buys RPO. The production posture is the hybrid, not snapshots alone.

The RTO/RPO table: three restore paths, side by side

Here is the core comparison. Treat every number as a range driven by the sensitivity column — anyone quoting you a single RTO for "Postgres restore" without naming these variables is selling you the flattering end of their own range.

Restore pathTypical RTOTypical RPOSensitivity: what moves the number
Volume-snapshot restore (CNPG + snapshot-capable CSI)Minutes (benchmark: ~2 min at ~4.5TB)Snapshot instant, or near-zero with WAL replay from object storeCSI snapshotter delta handling; WAL volume since snapshot; Postgres crash-recovery time on start
Object-store replay (pgBackRest/barman base + WAL)Tens of minutes to hours at terabyte scaleNear-zero with continuous WAL archivingDatabase size (full byte download); object-store throughput; WAL replay length
Managed PITR (RDS, Cloud SQL, Azure)Minutes at small sizes; hours at large onesNear-zero with PITR enabledSame size dependence — Azure states RTO outright as "size of the data to restore + log recovery time"; one operator guide cites ~30 MB/s restore throughput

Two things fall out of this table that the headline alone does not tell you.

First, snapshots compress the size variable nearly out of RTO. Object-store and managed restores both scale with bytes; snapshot restores scale with changed blocks plus Postgres startup. That is why the gap widens with database size — below roughly 500GB, the project's own guidance says object stores are perfectly reasonable, and the snapshot advantage is real but not transformative. Past a terabyte, it is a different sport.

Second, mind what a managed vendor's SLA actually covers: availability and uptime, not restore duration. Nobody's SLA promises your 4TB point-in-time restore finishes in minutes — that duration is the same size-bound replay problem, just operated by someone else. A self-hosted platform flips this honestly: you own the restore path end to end, so the RTO you promise is one you measured yourself on your own storage, not a number inferred from an uptime SLA that never mentioned restores.

Catch #1: your CSI driver has to support snapshots — Hetzner's doesn't

Snapshot recovery is a storage feature wearing a Kubernetes costume. Everything above assumes a CSI driver that implements snapshot creation — and this is where a self-hosted fleet on Hetzner hits a verified wall.

The Hetzner Cloud CSI driver (hcloud-csi) advertises no CREATE_DELETE_SNAPSHOT capability: its controller capabilities list exactly volume create/delete, publish/unpublish, expand, and list. Hetzner's own documentation states plainly that it provides no backups or snapshots for Volumes, and server snapshots explicitly exclude attached volumes. The upstream feature requests for snapshot and clone support (hetznercloud/csi-driver issues #20, #88, #140, #849) have been open for years, and Hetzner does not plan to add them.

This does not make the CNPG snapshot story irrelevant on Hetzner — it makes your storage choice load-bearing. You have two honest paths:

Path A: run snapshot-capable storage inside your fleet. Longhorn and Ceph both back Kubernetes VolumeSnapshots on top of Hetzner machines, and both are established CNPG companions — Longhorn for the simpler single-cluster case, Ceph (RBD snapshots plus RGW as the WAL object-store target) when you want the snapshot base and the WAL archive on infrastructure you fully control. You pay in operational surface: a storage system to run, monitor, and capacity-plan. What you buy is the minutes-scale RTO row of the table above.

Path B: stay on barman object-store backups plus continuous WAL archiving. This works on any storage, including plain hcloud-volumes, because the backup target is an S3-compatible store, not the CSI layer. Your RTO is the object-store row — fine for small tenant databases, hours-bound at terabyte scale — but your RPO is near-zero and your region-loss story (next section) is already half built.

What you cannot do is declare volumeSnapshot backups against a StorageClass whose provisioner has no snapshotter and expect anything but an error. Verify first: kubectl get volumesnapshotclass should show a class backed by a driver that actually implements snapshots, and your first restore drill should prove it before any tenant database depends on it.

Catch #2: snapshots don't leave the datacenter

Even with snapshot-capable storage, a volume snapshot is a same-array artifact. It protects against the failures that live above the storage layer — a dropped table, a bad migration, a corrupted primary, a lost node — and it does so faster than anything else in the table. It does not protect against losing the datacenter, the array, or the cluster whose etcd remembers the snapshots exist.

That is the half of the DR promise only the object store covers. Continuous WAL archiving plus periodic base backups to S3-compatible storage in a different failure domain is what turns "fast local restore" into "actual disaster recovery": region loss, storage-backend loss, or a control-plane incident that takes the snapshot metadata with it. CloudNativePG's own recovery docs frame exactly this hybrid — restore the base from the snapshot, replay WAL from the object store — and the replica-cluster pattern extends it across regions where the storage class can relay snapshots between clusters.

So the production answer is not "snapshots instead of pgBackRest/barman." It is tiered: snapshots for the RTO your tenants feel on ordinary bad days, object-store backups plus WAL for the RPO and region-loss survival your business-continuity plan audits on the worst one. Budget both. Drill both. The snapshot drill proves your two minutes; the object-store drill proves you still have a database when the datacenter does not.

What to actually promise and run

If your platform runs tenant Postgres through CloudNativePG on machines you own, here is the runbook that turns this post into an operable DR posture:

  1. Verify snapshot support before you promise snapshot RTO. Confirm a VolumeSnapshotClass exists for your StorageClass, take a snapshot backup, and restore a scratch cluster from it. On Hetzner Cloud Volumes, expect this step to fail — that failure is the finding, and it routes you to Path A or B above deliberately instead of discovering it during an incident.
  2. Run the hybrid, not one half. Schedule snapshot backups for fast local restore and keep continuous WAL archiving plus object-store base backups for PITR granularity and region loss. Either half alone is a gap: snapshots alone cap your RPO at the snapshot instant with no off-site copy; object store alone leaves terabyte-scale RTO in the hours band.
  3. Measure your own RTO and promise that. Restore-drill each tenant size class on your actual storage and record the range: snapshot-provision time, Postgres startup and crash recovery, WAL replay for your typical WAL volume. Your promised RTO is the top of your measured range plus headroom — never the benchmark's two minutes quoted raw.
  4. Set RPO per path and say so. Near-zero RPO comes from WAL archiving frequency and object-store durability, independent of the snapshot cadence. Document both numbers (snapshot cadence, WAL shipping lag) so a tenant asking "how much data could I lose" gets the WAL answer, not the snapshot schedule.
  5. Drill the path you hope to never use. The object-store-only restore (simulating snapshot unavailability) is the drill teams skip because it is slow. It is slow when it matters most, so run it on a schedule and keep the measured time next to the snapshot number. Two RTOs, honestly labeled, beat one optimistic one.

CloudNativePG keeps raising the ceiling here — the project has since shipped declarative Image Catalogs for extension management (1.29) and GitOps-friendly role management plus lease-based primary election (1.30) — but the DR math in this post does not depend on the newest release. It depends on your storage driver, your WAL archiving, and whether you drilled the restore before you promised it.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Standard Kubernetes storage primitives mean operators run CloudNativePG themselves with the same snapshot and WAL tooling described here. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex