Data AWS

Configure PostgreSQL Continuous Archiving and Point-in-Time Recovery with pgBackRest to S3

At 02:14 on a Tuesday a release migration on a fintech’s primary PostgreSQL 16 cluster runs UPDATE ledger_entry SET status = 'void' with the WHERE clause accidentally commented out, and 9.4 million rows flip to void before the on-call kills the session at 02:17. The nightly pg_dump finished at 12:30 the previous night — fourteen hours stale — so restoring it would discard a full trading day of inserts. What the DBA actually needs is to rewind the database to 02:13:30, the instant before the bad transaction committed, and not one second further. That is point-in-time recovery (PITR), and pg_dump structurally cannot do it: a logical dump is a snapshot of one moment, with no way to replay forward to an arbitrary later instant. This guide builds the capability that can — pgBackRest taking full, differential and incremental backups, PostgreSQL continuously archiving its write-ahead log (WAL) to Amazon S3, and a rehearsed restore to an exact timestamp, transaction ID, or named restore point.

PITR works because PostgreSQL already writes every change to the WAL before it touches a heap or index page (write-ahead logging is how the database survives a crash at all). If you keep one base backup plus an unbroken chain of WAL segments from that backup forward, you can restore the base and then replay WAL up to any recovery_target_time, recovery_target_xid, recovery_target_lsn, or named recovery_target_name in between. The base backup is your floor; the WAL chain is the continuous fabric that lets you land anywhere above it. pgBackRest is the tool that makes this robust at production scale where hand-rolled archive_command = 'aws s3 cp %p ...' falls apart: it does parallel compressed backups, true block-level incrementals, per-file SHA checksums, automatic retention expiry, asynchronous WAL push/get to keep up with a busy primary, repository encryption, and first-class S3 support so your backups and WAL live in durable, versioned object storage rather than on the disk that just failed.

By the end you will run a complete pgBackRest-to-S3 setup from an empty bucket to a verified restore: provision the repository, configure the stanza, turn on archiving correctly (the half everyone forgets), take and schedule backups, and — the part that actually matters at 02:14 — execute a clean PITR to a chosen target on a throwaway host, prove it landed on the right transaction, and fork a new timeline. We treat the restore runbook, retention math, verification and monitoring as first-class, because a backup you have never restored is a hypothesis, not a backup.

What problem this solves

The gap between “we take backups” and “we can recover to the second before the incident” is where most data-loss postmortems live. Three failure modes recur. First, coarse recovery granularity: nightly pg_dump or a daily snapshot means your worst-case data loss — your recovery point objective (RPO) — is up to 24 hours, and a 02:14 mistake costs you everything since the last dump. Second, backups co-located with the thing they protect: snapshots on the same EBS volume, or backups on the DB host’s local disk, die with the instance, the volume, or the ransomware that encrypts the box. Third, untested restores: teams discover their archive_command was silently failing — or their backup was encrypted with a passphrase nobody saved — only during the real incident, when there is no time to learn.

What breaks without this capability is concrete and expensive. A fat-fingered UPDATE/DELETE with no WHERE, a bad migration, an application bug that corrupts a table over hours, a dropped schema, or full disk loss — none of these are recoverable to a precise point from a logical dump alone. The team either loses hours of data (restore the stale dump) or spends the incident reconstructing rows by hand from application logs. Meanwhile pg_wal/ on a primary whose archive_command is broken fills the disk silently until PostgreSQL refuses writes and the database goes down — a backup misconfiguration causing the very outage it was meant to prevent.

Who hits this: anyone running self-managed PostgreSQL on EC2, on-prem, or in a container where the managed-service safety net (RDS automated backups, Aurora continuous backup) is not doing it for you. Regulated workloads — fintech, healthcare, anything with an auditor — additionally need the backups to be immutable (ransomware targets backups first) and encrypted, with recoveries performed under a named, audited identity and gated through change control. This guide builds all of that: pgBackRest to S3 with Object Lock and client-side encryption, a least-privilege IAM identity, and a runbook a sleepy on-call can follow without improvising.

To frame the whole field before the deep dive, here is every moving part this guide assembles, what it does, and what fails without it:

Component Role in the system What breaks if it is missing or wrong
WAL archiving (archive_command) Ships every completed WAL segment to the repo continuously No PITR — you can only restore to a backup boundary; RPO = backup interval
pgBackRest binary Orchestrates backups, archiving, restore, expiry, verify Hand-rolled aws s3 cp loses checksums, retries, retention, async
Stanza Named config binding one PG cluster to its repo No isolation between clusters; ambiguous backup sets
S3 repository Durable, off-host store for backups + WAL Backups die with the host/volume; no off-site copy
Full / diff / incr backup The base your WAL replays from Nothing to replay onto; WAL alone cannot reconstruct the DB
restore --type=time/xid/name Replays WAL from a base up to a precise target Coarse, whole-backup-only recovery
Retention (repo1-retention-full) Expires old backups + the WAL they pin Repo grows without bound; S3 bill climbs forever
check / verify Proves archiving works and backups are intact You learn the backup is broken during the real incident
Object Lock + encryption Immutable, opaque backups off-host Ransomware deletes backups; a leaked key reads them
Monitoring (pg_stat_archiver, info) Surfaces archive lag, failures, RPO drift Silent failure: disk fills, or you have no recent good backup

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should administer a PostgreSQL 13+ primary (examples use PostgreSQL 16 on Ubuntu 22.04, data directory /var/lib/postgresql/16/main, port 5432) with sudo/root and the ability to restart the service once through change control. You need pgBackRest 2.50+ installable on the DB host (the PGDG apt repo ships current builds; the distro package is often older and may lack repo1-block). You need an S3 bucket you control — examples use s3://kv-pgbackrest-prod in ap-south-1 — and an IAM role or user scoped to that one bucket (policy shown in the deep section). Comfort with psql, basic WAL/checkpoint concepts, and reading JSON output helps; you do not need replication or HA configured first (PITR is orthogonal to streaming replication, though they compose well).

Where this sits: PITR is the recovery half of a PostgreSQL durability story whose other halves are crash safety (the WAL itself), high availability (streaming replicas, failover), and logical export (pg_dump for portability and long-term archival). pgBackRest does not replace replicas — a replica protects against host failure with near-zero RPO/RTO but happily replicates a bad DELETE to every node in milliseconds. PITR is the tool for logical disasters (bad data, bad migration, dropped object) and for rebuilding after physical loss. It pairs naturally with the same skills used elsewhere in this corpus.

A quick map of who owns what during a real PITR incident, so you call the right person fast:

Layer What lives here Who usually owns it What it can cause / fix
PostgreSQL primary WAL generation, archive_command, data dir DBA / platform Archiving broken → no PITR; disk full → outage
pgBackRest config Stanza, repo options, encryption, retention DBA / platform Wrong target/timezone → restore to wrong instant
S3 bucket + IAM Durability, Object Lock, versioning, policy Cloud / security Over-broad policy → ransomware deletes backups
KMS + cipher passphrase At-rest and client-side encryption keys Security / secrets team Lost passphrase → unreadable backups
Change control / ITSM Approval to restart PG, to run a restore SRE / on-call lead Ungoverned restore over prod → turns loss into outage
Monitoring Archive lag, backup age, verify failures SRE / observability No alert → silent RPO drift, stale-backup surprise

Core concepts

Six mental models make every later step obvious.

Write-ahead logging is the substrate; PITR is built on top of it. PostgreSQL writes every change first to the WAL — a sequential stream of 16 MB segment files in pg_wal/ — and only later flushes the actual data pages at a checkpoint. This is what lets the database recover from a crash: replay WAL since the last checkpoint and you are consistent. PITR generalises this: if you keep a base backup (a copy of the data directory taken while the server runs, with the WAL needed to make it consistent) plus every WAL segment generated after it, you can restore the base and replay WAL forward to any point you choose. The base is the floor; the WAL is the continuous staircase above it.

pg_dump and a physical backup are different animals. pg_dump is a logical export — INSERT/COPY statements or a custom archive that recreates objects; it is portable across versions and architectures, perfect for moving or archiving a database, and useless for PITR because it captures one instant with no forward-replay. A physical backup (what pgBackRest takes) is a byte-level copy of the cluster’s files plus WAL; it is version- and platform-specific but is the only thing you can replay WAL onto. You want both for different jobs; this guide is entirely about the physical/WAL path.

pgBackRest organises everything around a stanza and a repository. A stanza is a named configuration that binds one PostgreSQL cluster (its pg1-path, port, etc.) to one or more repositories where backups and WAL land. kv-prod is a stanza; repo1 is its S3 repository. The stanza name threads through every command (--stanza=kv-prod). One host can run several stanzas (several clusters); one stanza can write to several repos (e.g. S3 plus a local NVMe cache) for belt-and-braces durability. stanza-create is the one-time act of initialising the repo layout for a cluster.

Backups come in three intensities, and they chain. A full backup copies every file. A differential copies everything changed since the last full. An incremental copies everything changed since the last backup of any type (full, diff, or incr). Restoring a full needs only itself; restoring a diff needs the diff plus its parent full; restoring an incr needs the incr plus every backup back to its full. pgBackRest tracks this backup reference list automatically and, with --delta, only fetches files that differ from what is already on disk — so an incr restore pulls far less than a full.

The archive_command is a contract, and its return code is load-bearing. PostgreSQL calls archive_command for each completed WAL segment; if the command exits 0, PostgreSQL considers the segment safely archived and may recycle it; if it exits non-zero, PostgreSQL keeps the segment in pg_wal/ and retries later. This is why you point archive_command at pgbackrest ... archive-push and not a bare aws s3 cp: pgBackRest returns the right code, retries, deduplicates, optionally pushes asynchronously, and never silently drops a segment. A broken archive_command that returns 0 anyway is the worst case — PostgreSQL recycles WAL you never actually archived, and your chain has a hole.

Recovery has a target and a timeline. When you restore for PITR you set a recovery target — a time, an LSN, a transaction id, or a named restore point — and PostgreSQL replays WAL until it reaches that target, then stops. The moment you let it run past recovery and start accepting writes, history forks: PostgreSQL increments the timeline ID (the first 8 hex digits of a WAL filename) so the new branch’s WAL never collides with the old. Understanding timelines explains why you can restore the same base to different targets repeatedly, and why your very next action after a successful PITR is to take a fresh full backup so the new timeline has its own baseline.

The vocabulary in one table

Before the deep sections, pin down every moving part; the glossary repeats these for lookup, this table is the model side by side:

Concept One-line definition Where it lives Why it matters to PITR
WAL segment A 16 MB file of sequential change records pg_wal/, then the repo The fabric you replay; a gap breaks recovery
Base backup Physical copy of the data dir + needed WAL The repo (...F folder) The floor you replay onto
Stanza Named config binding one cluster to repos pgbackrest.conf Every command targets a stanza
Repository (repo1) Where backups + WAL are stored (here S3) S3 bucket + repo1-path Off-host durability; survives the box
archive_command What PG runs per completed WAL segment postgresql.conf The continuous-archiving contract
Full / diff / incr Backup intensities that chain The repo Trade storage/time vs restore complexity
Recovery target Where replay stops (time/xid/lsn/name) restore --type/--target The “to the second” in PITR
Timeline (TLI) History branch ID after a recovery WAL filename prefix Forks so old and new WAL never collide
Retention How many backups to keep before expiry repo1-retention-* Defines your recovery window + repo size
recovery.signal File telling PG to enter recovery on start Restored data dir pgBackRest writes it; presence = “recover”
pg_stat_archiver View of archiving success/failure/lag The running server Your primary archiving-health signal
Object Lock S3 WORM retention on objects The bucket Makes recent backups undeletable

How continuous WAL archiving works

WAL archiving is the half people skip, and without it you have backups but no PITR — you can only restore to the instant each backup finished. Get it right first; everything else assumes a healthy chain.

wal_level, archive_mode, and what needs a restart

PostgreSQL must be told to retain enough WAL detail and to hand completed segments to your archiver. Four settings do the work, and the trap is that two of them require a full restart — setting them with ALTER SYSTEM and a reload does nothing until you restart, so teams “enable PITR,” reload, and discover months later it was never on.

sudo -u postgres psql <<'SQL'
ALTER SYSTEM SET wal_level = 'replica';        -- minimum for archiving + replicas
ALTER SYSTEM SET archive_mode = 'on';          -- enable archiving (restart required)
ALTER SYSTEM SET archive_command = 'pgbackrest --stanza=kv-prod archive-push %p';
ALTER SYSTEM SET archive_timeout = '60';       -- force a segment at least every 60s
ALTER SYSTEM SET max_wal_senders = 10;         -- headroom for backups + replicas
SQL

%p is the path to the WAL segment PostgreSQL wants archived; pgBackRest reads it and copies the segment to the repo. The settings, their effect, and the restart/reload behaviour:

Setting What it controls Default Recommended for PITR Restart or reload?
wal_level Detail recorded in WAL replica (PG10+) replica (or logical if you also need logical decoding) Restart
archive_mode Whether completed WAL is archived off on Restart
archive_command Command run per completed segment empty pgbackrest --stanza=... archive-push %p Reload
archive_timeout Max seconds before a partial segment is forced 0 (off) 60 (caps idle-DB RPO at ~60s) Reload
max_wal_senders Concurrent WAL sender processes 10 (PG13+) 10+ Reload
archive_library Archive module (PG15+ alternative to command) empty leave empty unless using a module Restart

The restart is the single disruptive step in this whole build — schedule it through change control:

sudo systemctl restart postgresql@16-main
sudo -u postgres psql -c "SHOW archive_mode;"      # 'on'
sudo -u postgres psql -c "SHOW archive_command;"   # the pgbackrest line

archive_timeout and your RPO floor

On a busy database, segments fill and are archived constantly, so the gap between “last archived” and “now” is tiny. On an idle or low-traffic database, a segment might not fill for hours, and until it is archived its changes are not in the repo — so a failure could lose everything since the last archived segment. archive_timeout forces PostgreSQL to switch to a fresh segment (archiving the current one, even partially filled) at least every N seconds. Setting archive_timeout = 60 means even a quiet database archives at least once a minute, capping your RPO at roughly 60 seconds. The cost is one extra (mostly empty, but still 16 MB before compression) segment per minute on idle clusters — which zst compression and repo1-bundle make cheap.

archive_timeout Idle-DB worst-case RPO Extra segments on idle DB When to use
0 (default, off) Until the next segment naturally fills (could be hours) None Never for PITR; only if you accept coarse RPO
300 (5 min) ~5 minutes ~12/hour idle Low-change clusters, relaxed RPO
60 (1 min) ~60 seconds ~60/hour idle The common production choice
10 ~10 seconds ~360/hour idle Aggressive RPO; watch S3 PUT cost

Synchronous vs asynchronous archive-push

By default pgBackRest archives each segment synchronously within the archive_command call. On a high-write primary that can become a bottleneck: if segments fill faster than they can be uploaded to S3 (which has real round-trip latency), pg_wal/ backs up and the disk fills. pgBackRest’s asynchronous archiving decouples this — PostgreSQL’s archive_command returns immediately after handing the segment to a pgBackRest async process, which uploads in parallel batches in the background. This is essential on busy systems and harmless on quiet ones.

# In the [global] section of pgbackrest.conf
archive-async=y
archive-push-queue-max=4GiB      ; cap the local async queue; alert before this
spool-path=/var/spool/pgbackrest ; local staging for async segments
process-max=4                    ; parallel upload workers
Mode How it behaves Throughput ceiling Risk Use when
Synchronous (default) archive_command blocks until upload done One segment at a time, S3 RTT-bound pg_wal/ fills under bursty load Low/moderate write rate
Asynchronous (archive-async=y) Returns fast; background parallel upload process-max segments in parallel Queue can grow if S3 is down (cap it) High write rate, production default

archive-push-queue-max is a safety valve: if S3 is unreachable and the async queue grows past this, pgBackRest starts erroring the push so PostgreSQL retains WAL in pg_wal/ (the correct behaviour) rather than letting the spool balloon. Alert on queue depth well before it.

What archive-push actually does

A subtle but important property: archive-push is idempotent and safe under retries. If PostgreSQL calls it for a segment that is already in the repo (e.g. a retry after a transient network blip), pgBackRest compares checksums — if identical it succeeds silently; if the same segment name has different contents (a sign of a serious problem like two primaries archiving to one stanza), it errors loudly rather than overwrite. This is exactly the correctness a bare aws s3 cp lacks, and the reason “just script it with the AWS CLI” is a trap for WAL.

Installing and configuring pgBackRest

With archiving understood, install the binary and write the configuration that defines the repository and stanza.

Install from the right source

The distro package is frequently a major version behind and may lack newer options (repo1-block, repo1-bundle tuning). Use the PGDG repository for a current build:

# PGDG repo (Ubuntu/Debian) gives current pgBackRest
sudo apt-get install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y pgbackrest
pgbackrest version   # expect 2.50+ ; older lacks block incrementals

# Create the directories pgBackRest needs, owned by postgres
sudo install -d -o postgres -g postgres -m 750 /etc/pgbackrest
sudo install -d -o postgres -g postgres -m 750 /var/log/pgbackrest
sudo install -d -o postgres -g postgres -m 750 /var/lib/pgbackrest
sudo install -d -o postgres -g postgres -m 750 /var/spool/pgbackrest

The configuration file, option by option

Write /etc/pgbackrest/pgbackrest.conf. In production this file is rendered by Ansible from a Jinja2 template so every host is byte-identical and the S3 key and cipher passphrase are injected from a secrets store, never hardcoded.

[global]
# --- repository: an S3 bucket ---
repo1-type=s3
repo1-s3-bucket=kv-pgbackrest-prod
repo1-s3-endpoint=s3.ap-south-1.amazonaws.com
repo1-s3-region=ap-south-1
repo1-s3-key-type=auto              ; use the host's IAM role / instance profile
repo1-s3-uri-style=host             ; host-style URLs (vs path) for AWS S3
repo1-path=/pgbackrest              ; key prefix inside the bucket

# --- encryption: client-side, on top of S3 SSE-KMS ---
repo1-cipher-type=aes-256-cbc       ; encrypt in pgBackRest before upload
repo1-cipher-pass=ENV:PGBACKREST_REPO_CIPHER  ; passphrase from env, never literal

# --- retention: defines the recovery window ---
repo1-retention-full=4              ; keep 4 full backups (+ the WAL they pin)
repo1-retention-diff=6              ; keep 6 differentials
repo1-retention-archive-type=full   ; expire archive in line with full retention

# --- efficiency ---
repo1-bundle=y                      ; bundle small files -> far fewer S3 objects/PUTs
repo1-block=y                       ; block-level incrementals (smaller incrs)
compress-type=zst                   ; Zstandard: great ratio, low CPU
compress-level=6
process-max=4                       ; parallel workers for backup/restore/archive

# --- WAL archiving behaviour ---
archive-async=y
archive-push-queue-max=4GiB
spool-path=/var/spool/pgbackrest

# --- operational ---
start-fast=y                        ; force an immediate checkpoint at backup start
log-level-console=info
log-level-file=detail
log-path=/var/log/pgbackrest

[kv-prod]
pg1-path=/var/lib/postgresql/16/main
pg1-port=5432
pg1-user=postgres

The repository options that matter, with the defaults and the trade-offs:

Option Purpose Default Recommended Trade-off / gotcha
repo1-type Backend kind posix s3 Also azure, gcs, sftp
repo1-s3-key-type How AWS creds are obtained shared auto (IAM role/profile) auto avoids static keys in the file
repo1-s3-uri-style host vs path URLs host host for AWS Some S3-compatibles need path
repo1-path Key prefix in the bucket / /pgbackrest Lets one bucket host several stanzas
repo1-cipher-type Client-side encryption none aes-256-cbc Lose the pass → backups unreadable
repo1-bundle Combine small files n y Fewer S3 PUTs/objects; big cost win on busy clusters
repo1-block Block-level incrementals n y (2.46+) Much smaller incrs on large, partially-changed tables
repo1-retention-full Full backups to keep none (∞) match recovery window Without it the repo grows forever
repo1-retention-archive-type What archive retention follows full full Controls how far back WAL is kept

The compression and parallelism options, side by side:

Option Values Default Effect When to change
compress-type none/gz/lz4/zst/bz2 gz zst = best ratio/CPU balance lz4 for max speed; none if storage is free and CPU scarce
compress-level 0–9 (zst: 1–19 internally mapped) type-specific Higher = smaller + slower Lower on CPU-bound hosts
process-max 1–N 1 Parallel workers Raise on multi-core hosts with bandwidth
start-fast y/n n Immediate checkpoint at backup start y to start backups promptly (slight I/O spike)
delta y/n n Restore/backup only changed files y makes restores re-fetch far less

The cipher passphrase: the one secret you must not lose

repo1-cipher-pass=ENV:PGBACKREST_REPO_CIPHER tells pgBackRest to read the repository encryption passphrase from an environment variable rather than have it sit in the config file. The variable is populated at process start from a secrets manager (Vault, AWS Secrets Manager, SOPS) so the passphrase never lands on disk. This passphrase is non-recoverable. Client-side encryption means the bytes in S3 are opaque even to AWS; lose the passphrase and every backup is permanently undecryptable — a backup you cannot decrypt is not a backup. Store it as the single source of truth in your secrets manager, document where it lives, and back that up out-of-band. Treat rotating it as a deliberate operation (new full backups under the new key; old backups still need the old key until they expire).

Initializing the stanza and taking backups

Now create the stanza and prove the wiring before you depend on it.

stanza-create and check

# Make the cipher passphrase available (in prod, sourced from a secrets manager)
export PGBACKREST_REPO_CIPHER='<from-your-secrets-manager>'

# One-time: initialise the repository layout for this cluster
sudo -E -u postgres pgbackrest --stanza=kv-prod stanza-create

# THE most important command in this guide:
sudo -E -u postgres pgbackrest --stanza=kv-prod check

check is the command that earns its keep: it forces a WAL switch and confirms the segment actually arrived in the repository, validating the entire archiving path end to end. Run it after setup, after any config change, and on a schedule — it catches a broken archive_command before you build a month of expectations on a chain with a hole.

Command What it does Run when Failure means
stanza-create Initialise repo layout for the cluster Once, at setup Repo/perm/connectivity problem
check Force WAL switch, confirm it reached the repo Setup + on a schedule Archiving is broken — fix before trusting PITR
stanza-upgrade Update stanza after a PG major upgrade After upgrading PostgreSQL Stanza/PG version mismatch
stanza-delete Remove the stanza and its backups Decommission only (Destructive — requires stop first)

The first full backup and the schedule

sudo -E -u postgres pgbackrest --stanza=kv-prod --type=full --log-level-console=info backup

Then wire a rotation. A pragmatic schedule for a busy cluster: a weekly full, daily differentials, and hourly incrementals. Run it from Jenkins, a GitHub Actions scheduled workflow, or a systemd timer so each run is logged, retried, and visible — not a silent cron line that fails unnoticed.

# crontab for the postgres user (or equivalent CI stages)
00 02 * * 0   pgbackrest --stanza=kv-prod --type=full backup   # Sun 02:00 weekly full
00 02 * * 1-6 pgbackrest --stanza=kv-prod --type=diff backup   # Mon-Sat differential
00 *  * * *   pgbackrest --stanza=kv-prod --type=incr backup   # hourly incremental

Choosing full vs differential vs incremental

The three intensities trade backup size/time against restore complexity and WAL-pinning. The defining numbers:

Backup type Copies Backup size Backup time Restore needs WAL it pins
Full (--type=full) Every file Largest Longest Just itself From this full forward
Differential (--type=diff) Changed since last full Grows over the week Medium Diff + its full From its parent full
Incremental (--type=incr) Changed since any last backup Smallest Shortest Incr + every backup back to its full From its parent full

How the choice maps to outcomes you care about:

If you want… Choose Because
Simplest, fastest restore More frequent fulls A full restores alone; no chain to walk
Smallest backup window / least I/O Incrementals Copy only what changed since the last backup
A middle ground that caps chain length Differentials Each diff stands on just its full, not a chain
Minimal repo growth on huge, lightly-changed DBs repo1-block=y + incrementals Block-level deltas, not whole changed files
Bounded restore time for an SLA Frequent fulls or diffs Long incr chains lengthen restore

A worked example of the chain. After Sunday’s full 20260607-020000F, Monday’s diff is ...F_20260608-020000D (everything since the full); Monday 14:00’s incr is ...F_20260608-140000I (everything since the 13:00 incr). Restoring to Monday 14:30 fetches the full, then the Monday diff, then each incr up to 14:00, then replays WAL the last 30 minutes. With --delta, only files that differ from what is already on the restore host are pulled.

Reading pgbackrest info

Inspect what you have at any time — this is the inventory you consult before a restore:

sudo -E -u postgres pgbackrest --stanza=kv-prod info
stanza: kv-prod
    status: ok
    cipher: aes-256-cbc

    db (current)
        wal archive min/max (16): 0000000100000A8B000000C0/0000000100000A91000000F4

        full backup: 20260607-020000F
            timestamp start/stop: 2026-06-07 02:00:01 / 2026-06-07 02:06:44
            wal start/stop: 0000000100000A8B000000C0 / 0000000100000A8B000000C3
            database size: 214.7GB, database backup size: 214.7GB
            repo1: backup set size: 41.2GB, backup size: 41.2GB

        diff backup: 20260608-020000F_20260608-020000D
            wal start/stop: 0000000100000A8E0000001A / 0000000100000A8E0000001C
            database size: 215.9GB, database backup size: 3.1GB
            repo1: backup set size: 41.6GB, backup size: 612MB
            backup reference list: 20260607-020000F

        incr backup: 20260608-020000F_20260608-140000I
            database size: 216.1GB, database backup size: 740MB
            repo1: backup set size: 41.7GB, backup size: 143MB
            backup reference list: 20260607-020000F, 20260608-020000F_20260608-020000D

The fields you actually read in an incident:

Field What it tells you Why it matters in a restore
wal archive min/max The continuous WAL range in the repo Your target time must fall within this range
backup reference list Which earlier backups this one needs Confirms the chain is intact before you restore
wal start/stop (per backup) The WAL range that backup spans Maps backups to LSNs/times for target selection
database size vs backup size Logical size vs what landed in the repo Shows compression + incremental savings
status ok / error detail A non-ok stanza needs fixing before trusting it

Performing a point-in-time recovery

The drill that matters at 02:14. The golden rule first, in bold because violating it turns a data-loss incident into an outage: always restore onto a separate host or a stopped clone first; never overwrite a live primary blind. Recover, validate, then decide how to bring the data back to production.

The restore, step by step

To rewind to 02:13:30 — the instant before the runaway UPDATE committed — on a recovery host: stop PostgreSQL (so the data dir can be replaced), restore the base with a recovery target, and let WAL replay stop precisely there.

# On the RECOVERY host (not the live primary). Stop PG so we can rebuild the data dir.
sudo systemctl stop postgresql@16-main

# Restore base + the WAL chain, targeting the exact instant.
# --delta only fetches files that differ from what is already on disk.
sudo -E -u postgres pgbackrest --stanza=kv-prod \
  --type=time --target="2026-06-10 02:13:30+05:30" \
  --target-action=promote \
  --delta \
  restore

pgBackRest restores the appropriate backup set (choosing the most recent backup at or before the target) and writes the recovery settings into postgresql.auto.conf, dropping a recovery.signal file so PostgreSQL enters recovery on start. Start the server; it replays WAL from the base up to the target and then stops as instructed:

sudo systemctl start postgresql@16-main

# Watch it reach the target and stop replaying:
sudo tail -f /var/log/postgresql/postgresql-16-main.log
#   ... "recovery stopping before commit of transaction 987654322, time 2026-06-10 02:13:30.4+05:30"
#   ... "archive recovery complete"

# Confirm we are out of recovery (promoted) and the data is correct:
sudo -u postgres psql -c "SELECT pg_is_in_recovery();"                         -- 'f' once promoted
sudo -u postgres psql -c "SELECT count(*) FROM ledger_entry WHERE status='void';"  -- back to normal

Choosing the recovery target

--type=time is the intuitive one, but pgBackRest supports several target kinds — some more precise than a wall-clock time:

--type --target value Replays until… Best for
time '2026-06-10 02:13:30+05:30' The first transaction after that timestamp “Just before 02:14” wall-clock incidents
xid '987654321' The given transaction id (commonly with --target-exclusive) “Up to but not including this known-bad transaction”
lsn 'A8E/1C0000F0' The given WAL log sequence number Surgical, when you know the exact LSN
name 'pre_ledger_migration' A restore point you created beforehand Gating a risky migration you pre-marked
immediate (none) Consistency is reached (end of base backup) Fastest restore; minimal replay
default (none) The end of the available WAL Full DR — recover everything, lose nothing

Examples of the non-time targets:

# To a transaction id, stopping BEFORE the bad xid is applied:
pgbackrest --stanza=kv-prod --type=xid --target="987654322" \
  --target-exclusive --target-action=promote --delta restore

# To a named restore point you created before a risky change:
#   (run on the primary, BEFORE the migration)
#   SELECT pg_create_restore_point('pre_ledger_migration');
pgbackrest --stanza=kv-prod --type=name --target="pre_ledger_migration" \
  --target-action=promote --delta restore

# Full disaster recovery — replay all available WAL, lose nothing:
pgbackrest --stanza=kv-prod --type=default --target-action=promote --delta restore

--target-action, --target-exclusive, and friends

The flags that govern how recovery stops and what happens at the boundary:

Flag Values Default Effect
--target-action pause / promote / shutdown pause At the target: pause (inspect, then promote), promote (open read-write, fork timeline), or shut down
--target-exclusive (flag) inclusive Stop before applying the target xid/lsn rather than including it
--target-timeline current / latest / <id> depends Which timeline to follow during recovery
--type (see table above) default The kind of target
--delta (flag) off Only fetch files that differ from what is on disk
--set a backup label latest suitable Restore from a specific backup set explicitly

A safer pattern for high-stakes restores is --target-action=pause: recovery stops at the target but does not open the database read-write. You connect, inspect (SELECT count(*) ...), and only when satisfied do you promote (SELECT pg_wal_replay_resume(); or pg_promote()), or — if you overshot — re-run the restore with a corrected target. Promoting is the irreversible step that forks the timeline.

After the restore: the timeline forks

The moment recovery promotes and the database accepts writes, the timeline ID increments (the WAL filename prefix goes from 00000001... to 00000002...) so the new branch’s WAL never collides with the original. Two consequences you must act on:

# 1. The new timeline has NO backup of its own yet — take a full immediately:
sudo -E -u postgres pgbackrest --stanza=kv-prod --type=full backup

# 2. If this recovery host IS the new production, archiving continues to the same
#    stanza on the new timeline — confirm check passes on the new timeline:
sudo -E -u postgres pgbackrest --stanza=kv-prod check
After a PITR you must… Why
Take a fresh full backup The new timeline has no baseline; PITR on it needs a floor
Run check Confirm archiving works on the new timeline
Decide the data-return path Is this host the new prod, or do you export the fixed data back?
Document the target used Auditors and the postmortem need the exact instant recovered to

Bringing the data back to production

The recovery host now holds the correct data; how you get it into production depends on the incident:

Scenario Recovery approach Data-return path
Whole-cluster disaster (host/volume lost) Restore to a new host, --type=default, promote Promote the recovery host as the new primary
A few tables clobbered, rest of DB fine Restore to a scratch host at the target time pg_dump/COPY only the affected tables back into live prod
Need to inspect before committing Restore with --target-action=pause Validate, then promote or re-target
Testing/forensics only Restore to an isolated host Investigate; discard the host afterward

For the “few tables clobbered” case — far more common than total loss — you do not overwrite the live primary. You restore a copy to the target time on a scratch host, then surgically copy the good rows back (pg_dump -t ledger_entry then restore that table, or COPY ... TO/FROM through a foreign data wrapper), so the rest of the production database (which has legitimate writes since 02:14) is untouched.

Retention, expiration, and the WAL chain

Backups and the WAL that links them grow forever unless you expire them. Retention defines both your recovery window and your S3 bill, and it interacts with the WAL chain in a way that catches people out.

How retention works

expire runs automatically after each backup (or can be run on its own). repo1-retention-full=4 keeps the 4 most recent full backups; when a 5th full is taken, the oldest full — and the diffs/incrs that depended on it, and the WAL segments only that old chain needed — are expired and deleted from S3. This is why retention is not just “delete old backup folders”: pgBackRest also prunes the now-unneeded WAL, which is what actually controls repo growth on a busy cluster.

[global]
repo1-retention-full=4              ; keep 4 fulls -> ~4 weeks with weekly fulls
repo1-retention-diff=6              ; keep 6 most recent differentials
repo1-retention-archive=2           ; keep PITR-capable WAL for the 2 newest fulls
repo1-retention-archive-type=full   ; archive retention is measured in fulls
Retention option What it bounds Example value Effect
repo1-retention-full Number of full backups kept 4 ~4 weeks of recoverability with weekly fulls
repo1-retention-full-type count or time count Keep N fulls, or fulls newer than N days
repo1-retention-diff Differentials kept 6 Bounds the diff sprawl within the window
repo1-retention-archive Fulls whose WAL stays PITR-capable 2 You can PITR within the 2 newest fulls’ spans
repo1-retention-archive-type Unit for archive retention full Tie WAL retention to full retention

The subtle part: retention-archive vs retention-full

Here is the gotcha that surprises people. repo1-retention-full=4 means four full backups are kept, but repo1-retention-archive controls how far back you retain the continuous WAL needed to do PITR between backups. If repo1-retention-archive is unset, pgBackRest keeps WAL for all retained fulls (full PITR across the whole window). If you set repo1-retention-archive=2, you keep PITR-capable WAL only for the 2 newest fulls; the older two fulls are still restorable to their exact backup point, but you cannot pick an arbitrary instant between them — their interstitial WAL was expired to save storage. Decide deliberately:

Configuration What you can recover to Storage cost
retention-archive unset Any instant within the full window (e.g. 4 weeks) Highest — keeps all interstitial WAL
retention-archive=2 (with 4 fulls) Any instant in the last 2 fulls’ span; only backup-points for the older 2 Lower — old WAL pruned
retention-full-type=time, retention-full=30 Any instant in the last 30 days (with archive matching) Sized to the time window

Match this to policy: “we must be able to recover to any second in the last 14 days, and to a daily restore point for 90 days” maps to time-based full retention of 90 days plus archive retention covering 14. Confirm the math before an auditor asks; the most common mistake is assuming retention-full=4 alone guarantees four weeks of to-the-second recovery when archive retention silently narrowed it.

Manual expiry and dry runs

# Run expiry on demand (also runs automatically after each backup):
sudo -E -u postgres pgbackrest --stanza=kv-prod expire

# Preview what WOULD be expired without deleting (dry run):
sudo -E -u postgres pgbackrest --stanza=kv-prod --dry-run expire

# Expire a specific backup set explicitly (e.g. a known-bad backup):
sudo -E -u postgres pgbackrest --stanza=kv-prod --set=20260601-020000F expire

Verification: proving the backups are good

A backup you have not restored is a hypothesis. pgBackRest gives you three independent levels of assurance, and you should run all three on a schedule.

check, verify, and a real restore

# 1. Archiving is healthy end-to-end (forces a WAL switch, confirms arrival).
sudo -E -u postgres pgbackrest --stanza=kv-prod check

# 2. Every backup + WAL file's checksum matches the repo manifest (catches bit-rot,
#    truncated uploads, tampering). Run after backups; it reads the whole repo.
sudo -E -u postgres pgbackrest --stanza=kv-prod verify

# 3. The only test that truly proves DR: restore to a scratch path and open it.
sudo -E -u postgres pgbackrest --stanza=kv-prod \
  --type=time --target="$(date -d '-1 hour' '+%F %T %z')" \
  --pg1-path=/var/lib/postgresql/16/restore_test \
  --target-action=promote --delta restore

The three levels, what each catches, and how often to run them:

Level Command Catches Misses Cadence
Archiving health check Broken archive_command, repo unreachable Backup corruption Hourly (monitoring)
Repository integrity verify Bit-rot, partial uploads, checksum drift Whether PG can actually start from it Daily/after backups
End-to-end restore full restore rehearsal Everything — the only proof PG starts and data is right (nothing — this is the gold standard) Monthly, ticketed

Why the restore rehearsal is non-negotiable

check proves WAL is flowing; verify proves the bytes in S3 match their checksums. Neither proves PostgreSQL will actually start from the restored files and serve correct data — that depends on version compatibility, the cipher passphrase being correct, the WAL chain being complete, and a dozen things that only a real restore exercises. Make the rehearsal a monthly, ticketed event: restore last hour’s data to a scratch host, start it, run a row-count sanity check, record the result, tear it down. A successful rehearsal closes a recurring change task — exactly the paper trail an auditor wants, and the only thing that lets you sleep before you have ever done a real one.

Reading repository state programmatically

For monitoring and CI gates, info --output=json exposes everything a script needs:

sudo -E -u postgres pgbackrest --stanza=kv-prod info --output=json | \
  jq '.[0].backup[-1] | {label, type, timestamp_stop: .timestamp.stop, error}'
JSON field Meaning Alert when
.[].status.code Stanza status (0 = ok) Non-zero
.[].backup[-1].timestamp.stop When the newest backup finished Older than your SLA (e.g. > 26h)
.[].backup[-1].error Whether the last backup errored true
.[].archive[-1].max Newest archived WAL segment Not advancing (archiving stalled)
.[].backup[].info.repository.size Repo size per backup Trending past budget

S3 repository hardening: encryption, Object Lock, versioning

The repository is the crown jewels of your recovery story; treat the bucket as immutable infrastructure provisioned by Terraform, not something clicked together in the console.

Provisioning the bucket and a scoped identity

resource "aws_s3_bucket" "pgbackrest" {
  bucket              = "kv-pgbackrest-prod"
  object_lock_enabled = true            # WORM; cannot be enabled after creation
}

resource "aws_s3_bucket_versioning" "pgbackrest" {
  bucket = aws_s3_bucket.pgbackrest.id
  versioning_configuration { status = "Enabled" }   # required for Object Lock
}

resource "aws_s3_bucket_server_side_encryption_configuration" "pgbackrest" {
  bucket = aws_s3_bucket.pgbackrest.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.pgbackrest.arn
    }
    bucket_key_enabled = true            # cuts KMS request cost dramatically
  }
}

resource "aws_s3_bucket_object_lock_configuration" "pgbackrest" {
  bucket = aws_s3_bucket.pgbackrest.id
  rule {
    default_retention {
      mode = "GOVERNANCE"                # or COMPLIANCE for un-overridable WORM
      days = 14                          # recent backups undeletable for 14 days
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "pgbackrest" {
  bucket = aws_s3_bucket.pgbackrest.id
  rule {
    id     = "expire-noncurrent"
    status = "Enabled"
    noncurrent_version_expiration { noncurrent_days = 35 }
  }
}

The IAM policy pgBackRest needs is the minimum to operate inside one bucket prefix — list, get, put, delete:

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::kv-pgbackrest-prod" },
    { "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::kv-pgbackrest-prod/*" }
  ]
}

Two layers of encryption, and why both

Layer Mechanism Protects against Key lives in
At-rest (server-side) S3 SSE-KMS Disk-level access to S3 storage AWS KMS (your CMK)
Client-side pgBackRest aes-256-cbc AWS itself / anyone reading the object Your secrets manager (the cipher pass)
In-transit TLS to the S3 endpoint Network interception Ephemeral TLS session keys

Client-side encryption is the meaningful control for a regulated shop: SSE-KMS means AWS holds a key that can decrypt your data; pgBackRest’s client-side cipher means the bytes are opaque before they ever reach S3, so even a hypothetical AWS-side compromise yields ciphertext. The trade-off is the irrecoverable-passphrase risk covered earlier.

Object Lock: GOVERNANCE vs COMPLIANCE

Object Lock applies WORM (write once, read many) retention to objects, so even a compromised DB credential — or ransomware that targets backups first — cannot delete recent backups within the retention window. Two modes:

Mode Who can shorten/remove the lock Use when
GOVERNANCE A principal with s3:BypassGovernanceRetention Operational immutability; you want a break-glass override
COMPLIANCE No one, including the root account, until expiry Hard regulatory WORM; accept that a fat-fingered long retention is permanent

Object Lock requires versioning, which is why both are enabled together. The lifecycle rule then expires non-current versions after the lock window to control cost. Crucially, retention via pgBackRest (expire) and S3 Object Lock interact: pgBackRest will try to delete expired objects, but Object Lock prevents deletion until the lock expires — so set the Object Lock window to be ≤ your pgBackRest retention, or expiry will error on still-locked objects. CrowdStrike Falcon on the DB host and Wiz scanning the Terraform in the PR catch drift (a bucket gone public, encryption removed, an over-broad policy) before it reaches production.

Monitoring, RPO, and RTO

A backup system you do not watch is a backup system that silently fails. The two questions monitoring must answer continuously: “is archiving healthy right now?” and “do we have a recent good backup?”

The signals and where they live

-- The primary archiving-health view: success, failure, and lag.
SELECT last_archived_wal, last_archived_time,
       last_failed_wal, last_failed_time,
       archived_count, failed_count
FROM pg_stat_archiver;
-- How much un-archived WAL is sitting locally (archiving falling behind):
SELECT pg_walfile_name(pg_current_wal_lsn()) AS current_wal,
       last_archived_wal,
       (pg_current_wal_lsn() - '0/0') AS current_lsn_bytes
FROM pg_stat_archiver;

The metrics that matter, their source, and the alert threshold:

Metric Source Healthy Alert threshold What a breach means
last_failed_wal pg_stat_archiver NULL Any non-NULL Archiving is failing — chain at risk
Time since last_archived_time pg_stat_archiver seconds–minutes > 5 × archive_timeout Archiving stalled; pg_wal/ filling
Newest backup age pgbackrest info JSON < backup interval > 26h (for daily) No recent backup; RPO drifting
Async queue depth spool-path size small Near archive-push-queue-max S3 trouble; WAL backing up
pg_wal/ disk usage filesystem stable > 75% Disk-full → PostgreSQL stops writes
verify result scheduled verify pass Any failure Repository corruption
Restore rehearsal monthly ticket pass Fail / overdue DR unproven

Push these into Datadog or Dynatrace via the PostgreSQL integration plus a small exporter parsing pgbackrest info --output=json. Wire the alerts so that any failed WAL archive, or no successful backup in 26 hours, pages on-call and auto-opens a ServiceNow incident — the gap is then tracked, not just logged. A successful monthly restore rehearsal closes a recurring ServiceNow change task.

RPO and RTO: setting and meeting the numbers

RPO (recovery point objective) is the maximum data loss you accept — driven almost entirely by archive_timeout and archiving health. RTO (recovery time objective) is how long recovery may take — driven by backup size, restore parallelism, network throughput from S3, and WAL-replay distance. The levers:

Objective Driven by To improve it… Cost of improving
RPO (data loss) archive_timeout, archiving reliability Lower archive_timeout; async archiving; alert on failures More S3 PUTs; monitoring effort
RTO (recovery time) Backup size, process-max, bandwidth, replay distance More frequent fulls/diffs (less replay); higher process-max; same-region repo; --delta More backup storage/I/O

Worked numbers: with archive_timeout=60 and healthy archiving, RPO is ~60 seconds. For RTO, a 40 GB compressed backup over a same-region S3 connection at, say, ~250 MB/s effective with process-max=4 restores in roughly 3–5 minutes, plus WAL replay — if the last full was 6 days ago you might replay a week of WAL (minutes to tens of minutes depending on write volume), whereas a daily full caps replay at one day. The decision: more frequent fulls trade storage for a tighter RTO. Map your stated SLA to a backup schedule rather than guessing:

Stated SLA Backup schedule Archive setting Expected RPO / RTO
RPO ≤ 1 min, RTO ≤ 15 min Daily full, hourly incr archive_timeout=60, async ~60s / ~5–15 min
RPO ≤ 5 min, RTO ≤ 1 h Weekly full, daily diff, hourly incr archive_timeout=300 ~5 min / up to ~1 h replay
RPO ≤ 1 min, RTO ≤ 5 min Twice-daily full, 15-min incr archive_timeout=30, async, high process-max ~30–60s / ~5 min

Architecture at a glance

The shape is deliberately simple, and the diagram traces the two independent flows that together make PITR possible. Read it left to right. The PostgreSQL primary (data dir /var/lib/postgresql/16/main, port 5432) runs the pgbackrest binary locally. On a schedule — driven by cron, a Jenkins job, or a GitHub Actions workflow for auditable, logged runs — pgBackRest pushes full, differential, and incremental backups to the S3 repository (s3://kv-pgbackrest-prod, prefix /pgbackrest, in ap-south-1), compressed with zst and encrypted client-side with aes-256-cbc on top of SSE-KMS. Independently and continuously, PostgreSQL’s archive_command hands every completed WAL segment to pgbackrest archive-push, which (asynchronously, in parallel) copies it to the same repository — so the gap between “last backup” and “now” is always covered by archived WAL, and archive_timeout=60 caps that gap at a minute.

Follow the recovery path back the other way: a standby or throwaway recovery host pulls a base backup plus the WAL chain from S3 with pgbackrest restore --type=time/xid/name, replays WAL to the chosen target, stops, and promotes — forking onto a new timeline. The bucket itself is hardened: Object Lock (GOVERNANCE) and versioning make recent backups undeletable by a compromised host, a KMS key encrypts at rest, and a lifecycle rule expires non-current versions. Terraform provisions the bucket, IAM, KMS key, and lifecycle; Ansible templates pgbackrest.conf so every node is byte-identical and the cipher passphrase is injected from a secrets store at runtime, never written to disk. The whole system is watched: pg_stat_archiver and pgbackrest info --output=json feed Datadog/Dynatrace, and a failed archive or a stale backup pages on-call and opens a ServiceNow incident.

PostgreSQL 16 primary archiving WAL continuously and pushing full, differential, and incremental backups via pgBackRest to an encrypted, Object-Lock S3 repository in ap-south-1, with a separate recovery host restoring a base backup plus the WAL chain to a point-in-time target and forking a new timeline, all provisioned by Terraform and Ansible and monitored through pg_stat_archiver into Datadog with ServiceNow incident automation

Real-world scenario

Meridian Pay runs a card-issuing ledger on a self-managed PostgreSQL 16 primary on EC2 (r6i.2xlarge, 8 vCPU / 64 GB, a 2 TB gp3 data volume) in ap-south-1, fronting an authorisation API at ~3,500 transactions/second peak. The data is ~280 GB and grows ~4 GB/day. For two years their backup story was a nightly pg_dump to a separate EBS volume plus daily EBS snapshots — and a vague belief that “we have backups.” The platform team is five engineers; the auditor’s annual question — “demonstrate recovery to a specific point in time” — had always been answered with a hopeful “we’d restore the snapshot,” which nobody had ever timed or tested.

The incident that changed everything came not from hardware but from a release. A 23:50 deployment shipped a data-backfill migration with a bug: a loop that was meant to update a few thousand dispute rows instead ran an unbounded UPDATE dispute SET state = 'resolved' across 2.1 million open disputes. It committed at 23:52. The on-call noticed the spike in “resolved” disputes at 00:08 and stopped the application. The nightly pg_dump had finished at 22:30 — so restoring it would lose 80 minutes of legitimate authorisation traffic (thousands of real card transactions), which for a payment processor is unacceptable: those are real settlements. The EBS snapshot was from 22:00, worse. They had no way to land on 23:51:30, the instant before the bad commit. The incident ran four hours and ended with engineers reconstructing the correct dispute states by replaying application audit logs into the live database by hand — error-prone and exhausting.

The postmortem action was unambiguous: build real PITR. They stood up pgBackRest to a new Object-Lock S3 bucket, turned on continuous WAL archiving with archive_timeout=60 and archive-async=y, and moved to a daily full / hourly incremental schedule with zst compression and repo1-bundle=y. The first full was 280 GB logical, 51 GB in the repo after compression; hourly incrementals averaged 180 MB. They wired pg_stat_archiver and pgbackrest info into Datadog with a page on any failed archive and on “no backup in 26 hours,” and scheduled a monthly ticketed restore rehearsal to a scratch instance.

The rehearsal paid off three weeks later, during a real repeat: a different bad migration clobbered an account_limit table at 14:22. This time the runbook was muscle memory. The on-call spun up a recovery instance, ran pgbackrest --stanza=meridian --type=time --target="2026-05-12 14:21:00+05:30" --target-action=pause --delta restore, started PostgreSQL, confirmed account_limit looked correct at the paused target, promoted, and — because only one table was affected — pg_dump -t account_limit from the recovery host and restored just that table into live production. Total data loss: zero. Time from detection to corrected production: 31 minutes. The bad migration had touched one table; the rest of the database, full of legitimate 14:22-onward authorisations, was never disturbed. Their numbers, before and after:

Capability Before (pg_dump + snapshots) After (pgBackRest + WAL to S3)
RPO (worst-case data loss) Up to 24 h (last dump/snapshot) ~60 seconds
Recovery granularity Whole-DB, to a backup boundary Any second, any xid, single table
Real incident recovery time ~4 h, manual log replay 31 min, scripted runbook
Backups immutable? No (deletable EBS volume) Yes (S3 Object Lock GOVERNANCE)
Restore ever tested? Never Monthly, ticketed, audited
Auditor’s PITR question “we’d restore the snapshot” Demonstrated live, with the runbook
Monthly storage cost ~₹6,000 (full dumps + snapshots) ~₹4,500 (compressed incr + WAL)

The lesson the team wrote on the runbook’s first page: “A backup you have never restored is a guess. WAL archiving is the difference between losing a trading hour and losing nothing.”

Advantages and disadvantages

The pgBackRest-to-S3 model both delivers second-level recovery and adds real operational surface. Weigh it honestly before committing:

Advantages Disadvantages
Point-in-time recovery to any second — the headline capability pg_dump/snapshots cannot match The archive_command must work continuously; a silent break ends PITR until noticed
Backups live off-host in durable S3 (11 nines), surviving host/volume/ransomware loss Restores depend on S3 reachability and bandwidth; a region-wide S3 issue blocks recovery
Block-level incrementals + zst make backups small and fast on large DBs Long incremental chains lengthen restore (RTO) unless you take frequent fulls/diffs
Per-file checksums + verify catch bit-rot and partial uploads automatically verify reads the whole repo (S3 GETs) — not free on large repos
Client-side encryption + Object Lock give regulated-grade immutability and opacity A lost cipher passphrase makes every backup permanently unreadable — a real footgun
Async, parallel archive/backup keeps up with busy primaries More moving parts to configure correctly than a cron’d pg_dump
Open-source, no licence cost; works on EC2/on-prem/containers identically You operate it — no managed-service safety net (unlike RDS/Aurora automated backups)
Composes with replication and pg_dump rather than replacing them PITR can’t undo a logical bug already replicated to standbys; it’s a separate recovery tool

The model is right when you run self-managed PostgreSQL and need real recovery granularity, immutability, and off-host durability — fintech ledgers, healthcare records, any cluster where 24-hour data loss is unacceptable and a managed service is not handling backups for you. It is overkill for a throwaway dev database (a nightly pg_dump is fine) and is not a substitute for HA — a streaming replica protects against host failure with near-zero RPO/RTO but cheerfully replicates a bad DELETE; PITR is the complement that recovers from the logical disasters replicas cannot. The disadvantages are all manageable, but only if you treat archiving health, the cipher passphrase, and the restore rehearsal as first-class, which is the entire point of building it properly.

Hands-on lab

Build the whole capability end to end and prove a real PITR, on a single host with a real PostgreSQL and a local pgBackRest repository (so it is free and self-contained — the S3 steps are identical except for the repo1-* lines, which are called out). The centerpiece is Step 9: you deliberately destroy data, then recover to the second before you destroyed it. Run on Ubuntu 22.04 (or any host with PostgreSQL 16 + pgBackRest 2.50+).

Step 1 — Install PostgreSQL 16 and pgBackRest.

sudo apt-get update
sudo apt-get install -y postgresql-16 postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y pgbackrest
pgbackrest version   # expect 2.50+
psql --version       # expect 16.x

Step 2 — Create the pgBackRest directories.

sudo install -d -o postgres -g postgres -m 750 /etc/pgbackrest
sudo install -d -o postgres -g postgres -m 750 /var/log/pgbackrest
sudo install -d -o postgres -g postgres -m 750 /var/lib/pgbackrest

Step 3 — Write pgbackrest.conf for a LOCAL repo (S3 variant in the comment).

sudo tee /etc/pgbackrest/pgbackrest.conf >/dev/null <<'CONF'
[global]
# Local repo for the lab. For S3, replace the next two lines with:
#   repo1-type=s3
#   repo1-s3-bucket=kv-pgbackrest-prod
#   repo1-s3-endpoint=s3.ap-south-1.amazonaws.com
#   repo1-s3-region=ap-south-1
#   repo1-s3-key-type=auto
repo1-path=/var/lib/pgbackrest
repo1-retention-full=2
repo1-bundle=y
repo1-block=y
compress-type=zst
compress-level=3
process-max=2
start-fast=y
log-level-console=info
log-level-file=detail

[lab]
pg1-path=/var/lib/postgresql/16/main
pg1-port=5432
pg1-user=postgres
CONF
sudo chown postgres:postgres /etc/pgbackrest/pgbackrest.conf
sudo chmod 640 /etc/pgbackrest/pgbackrest.conf

Step 4 — Turn on WAL archiving and restart PostgreSQL.

sudo -u postgres psql <<'SQL'
ALTER SYSTEM SET wal_level = 'replica';
ALTER SYSTEM SET archive_mode = 'on';
ALTER SYSTEM SET archive_command = 'pgbackrest --stanza=lab archive-push %p';
ALTER SYSTEM SET archive_timeout = '60';
ALTER SYSTEM SET max_wal_senders = 10;
SQL
sudo systemctl restart postgresql@16-main
sudo -u postgres psql -c "SHOW archive_mode;"      # on
sudo -u postgres psql -c "SHOW archive_command;"   # the pgbackrest line

Expected: archive_mode = on and the archive_command showing the pgBackRest line. (If archive_mode still says off, you reloaded instead of restarting — restart.)

Step 5 — Create the stanza and run check.

sudo -u postgres pgbackrest --stanza=lab stanza-create
sudo -u postgres pgbackrest --stanza=lab check

Expected: stanza-create completes, and check ends with INFO: check command end: completed successfully — proof the archiving path works end to end. If check fails here, stop and fix it; nothing downstream is trustworthy until it passes.

Step 6 — Create test data.

sudo -u postgres psql <<'SQL'
CREATE DATABASE ledger;
\c ledger
CREATE TABLE ledger_entry (id bigserial PRIMARY KEY, status text, amount numeric);
INSERT INTO ledger_entry (status, amount)
  SELECT 'posted', (random()*1000)::numeric(10,2) FROM generate_series(1, 100000);
SELECT count(*) AS total, count(*) FILTER (WHERE status='posted') AS posted FROM ledger_entry;
SQL

Expected: total = 100000, posted = 100000.

Step 7 — Take the first full backup.

sudo -u postgres pgbackrest --stanza=lab --type=full backup
sudo -u postgres pgbackrest --stanza=lab info

Expected: info shows one full backup with a database size and a (smaller) backup size, and a wal archive min/max range.

Step 8 — Note a safe target time, then take an incremental. Capture the current time before the destructive step so you have a precise PITR target.

# Record a target timestamp BEFORE we break anything (with timezone offset):
SAFE_TS=$(sudo -u postgres psql -tAc "SELECT now();")
echo "Safe target time: $SAFE_TS"

# Make a few more changes and take an incremental, so the WAL chain advances:
sudo -u postgres psql -d ledger -c \
  "INSERT INTO ledger_entry (status, amount) SELECT 'posted', 10 FROM generate_series(1,5000);"
sudo -u postgres pgbackrest --stanza=lab --type=incr backup

Expected: SAFE_TS prints a timestamp; the incremental backup completes. Total rows now 105,000.

Step 9 — Destroy data (the incident), then recover to the second before it. This is the whole point.

# Wait a couple of seconds so the destructive change is clearly AFTER SAFE_TS, then break it:
sleep 3
sudo -u postgres psql -d ledger -c \
  "UPDATE ledger_entry SET status='void';"   # the runaway UPDATE, no WHERE
sudo -u postgres psql -d ledger -c \
  "SELECT count(*) FILTER (WHERE status='void') AS voided FROM ledger_entry;"  # 105000 -> disaster

# Force the WAL containing the bad UPDATE to archive so it's available to replay around:
sudo -u postgres psql -c "SELECT pg_switch_wal();"

# --- RECOVER ---
sudo systemctl stop postgresql@16-main
sudo -u postgres pgbackrest --stanza=lab \
  --type=time --target="$SAFE_TS" \
  --target-action=promote --delta restore
sudo systemctl start postgresql@16-main

Step 10 — Verify the recovery landed before the disaster.

# Wait for recovery to finish, then confirm we're promoted and the data is correct:
sleep 5
sudo -u postgres psql -c "SELECT pg_is_in_recovery();"   # expect 'f'
sudo -u postgres psql -d ledger -c \
  "SELECT count(*) FILTER (WHERE status='void')  AS voided,
          count(*) FILTER (WHERE status='posted') AS posted,
          count(*) AS total
   FROM ledger_entry;"

Expected: pg_is_in_recovery = f, voided = 0, posted = 105000, total = 105000. The runaway UPDATE is gone — you recovered to the instant before it, keeping the 5,000 rows added in Step 8 but discarding the void. That is PITR working.

Step 11 — Fork a fresh baseline on the new timeline.

sudo -u postgres pgbackrest --stanza=lab --type=full backup   # the new timeline needs its own floor
sudo -u postgres pgbackrest --stanza=lab check                # archiving healthy on the new timeline
sudo -u postgres pgbackrest --stanza=lab info                 # note the new timeline in the WAL range

Validation checklist. You configured archiving, proved it with check, took full and incremental backups, deliberately destroyed data, recovered to the exact second before the damage with --type=time, confirmed zero data loss for legitimate writes, and forked a new timeline with a fresh full. The steps mapped to what each proves:

Step What you did What it proves Production analogue
4 Enable archiving, restart archive_mode/wal_level need a restart The one disruptive change in any real rollout
5 stanza-create + check The archiving path actually works The check you run before trusting PITR
7–8 Full + incr; record SAFE_TS The chain advances; you have a target Scheduled backups + knowing your target
9 Destroy, then --type=time restore PITR rewinds to a precise instant The 02:14 runaway-UPDATE recovery
10 Row counts after recovery The right data survived, the bad change didn’t The validation before you trust the restore
11 Fresh full on the new timeline A forked timeline needs its own baseline The mandatory post-PITR step

Cleanup.

sudo -u postgres pgbackrest --stanza=lab stop
sudo -u postgres pgbackrest --stanza=lab --force stanza-delete
sudo -u postgres psql -c "ALTER SYSTEM SET archive_mode = 'off';"
sudo -u postgres psql -c "ALTER SYSTEM RESET archive_command;"
sudo systemctl restart postgresql@16-main
sudo -u postgres psql -c "DROP DATABASE ledger;"
sudo rm -rf /var/lib/pgbackrest/*

Cost note. Run entirely locally the lab costs nothing but disk. The S3 variant (swap the repo1-* lines in Step 3) costs a few rupees for the storage and PUTs of a 100k-row table plus its WAL over an hour — well under ₹50 — and tearing down the bucket stops it.

Common mistakes & troubleshooting

The failure modes that bite in production, as a symptom → root cause → confirm → fix table you can read mid-incident, then the worst offenders expanded.

# Symptom Root cause Confirm (exact command / path) Fix
1 “Enabled PITR” but a restore can only reach backup boundaries; no WAL between archive_mode/wal_level set with ALTER SYSTEM + reload, never restarted SHOW archive_mode; returns off; pg_stat_archiver.archived_count = 0 Restart PostgreSQL; re-run pgbackrest check
2 pg_wal/ filling, disk approaching full, writes about to stop archive_command failing; PG retaining un-archived WAL SELECT last_failed_wal, last_failed_time FROM pg_stat_archiver; (non-NULL) Fix the cause pgBackRest logs (creds, endpoint, bucket); WAL drains once it succeeds
3 archive_command “succeeds” but segments aren’t in the repo A hand-rolled command returning 0 without uploading; or wrong stanza pgbackrest --stanza=X check fails; compare info WAL max to current Use pgbackrest archive-push (idempotent, correct return codes); fix --stanza
4 stanza-create/backup fails with S3 auth/permission error IAM identity missing list/get/put/delete, or wrong key type pgBackRest log shows AccessDenied/403; aws sts get-caller-identity Attach the scoped bucket policy; set repo1-s3-key-type=auto for instance role
5 Restore lands hours off the intended instant Naive timestamp in --target interpreted in the wrong timezone Server log: “recovery stopping … time <wrong tz>” Always pass an explicit offset (...+05:30) or run in UTC
6 Restore errors: target time outside available WAL Target older than wal archive min, or WAL expired by archive retention pgbackrest info wal archive min/max doesn’t bracket the target Pick a target within range; loosen repo1-retention-archive going forward
7 Restore completes but PG won’t start / can’t decrypt Wrong/missing repo1-cipher-pass on the restore host pgBackRest: “unable to … cipher”; PG won’t open Provide the exact cipher passphrase from the secrets manager
8 Restored DB immediately re-applies the bad change Recovery ran past the target (no --target-action, or inclusive xid) Log shows recovery continued to end of WAL Set --target-action=promote/pause; use --target-exclusive for xid
9 Incremental restore is huge / slow Restoring without --delta, re-fetching unchanged files Restore transfers ≈ full size despite small incr Add --delta so only differing files are pulled
10 Backups silently stopped days ago; nobody noticed No monitoring on backup age / archive failures pgbackrest info newest backup is days old Alert on last_failed_wal non-NULL and “no backup in 26h” → page + ServiceNow
11 expire errors; old objects won’t delete S3 Object Lock window longer than pgBackRest retention S3 shows AccessDenied on delete of locked objects Set Object Lock days ≤ pgBackRest retention; wait out the lock
12 Async archiving queue grows; eventually push fails S3 unreachable/slow; spool filling toward archive-push-queue-max spool-path size climbing; pgBackRest async log Restore S3 connectivity; raise queue cautiously; alert on queue depth
13 Backup fails: “stanza … does not exist” or version mismatch stanza-create not run, or PG was major-upgraded pgbackrest info missing the stanza / version warning Run stanza-create (new) or stanza-upgrade (after a PG upgrade)
14 Two hosts archiving to one stanza; integrity errors A failover left two primaries pushing the same WAL names pgBackRest errors on same-name/different-content WAL Ensure exactly one writer per stanza; fence the old primary

The ones that cause the most damage, expanded:

1. “Enabled” PITR that was never on. Setting wal_level/archive_mode with ALTER SYSTEM writes to postgresql.auto.conf, but these specific parameters only take effect on a restart — a reload silently ignores them. Teams enable archiving, reload, see no error, and assume PITR is live; the gap surfaces only during a real incident when there is no WAL between backups. Confirm: SHOW archive_mode; returns off, and SELECT archived_count FROM pg_stat_archiver; is 0 long after enabling. Fix: restart PostgreSQL, then pgbackrest check to prove archiving end to end. This is why check is in every runbook.

2. Archiving silently broken → disk fills. If archive_command fails, PostgreSQL correctly refuses to recycle the un-archived WAL and retains it in pg_wal/. On a busy database that fills the volume in hours; when the volume is full, PostgreSQL stops accepting writes — your backup misconfiguration becomes a production outage. Confirm: SELECT last_failed_wal, last_failed_time FROM pg_stat_archiver; returns a non-NULL failed segment, and pg_wal/ is growing. Fix: read the pgBackRest log for the actual cause (S3 credentials expired, endpoint wrong, bucket policy too tight, network egress blocked), fix it, and the queue drains. Prevent: alert on any non-NULL last_failed_wal and on pg_wal/ disk > 75%.

5. Timezone ambiguity sends you hours off. --type=time --target="2026-06-10 02:13:30" without an offset is interpreted in the server’s timezone, which on a UTC server is six hours adrift from an IST incident — you recover to 08:13 IST instead of 02:13, losing six hours of data or replaying past the bad change. Confirm: the server log’s “recovery stopping … time …” line shows a timezone you didn’t intend. Fix: always pass an explicit offset (02:13:30+05:30) or standardise on UTC everywhere and pass UTC targets. This single habit prevents the most embarrassing class of PITR error.

7. The unrecoverable cipher passphrase. Client-side encryption (repo1-cipher-type=aes-256-cbc) makes backups opaque even to AWS — and unreadable without the exact passphrase. A restore host that doesn’t have PGBACKREST_REPO_CIPHER set (or has the wrong value) fails to decrypt, and there is no recovery path. Confirm: pgBackRest errors with a cipher/decrypt message. Fix: supply the correct passphrase from the secrets manager. Prevent: store it as the single source of truth, back that up out-of-band, and verify the restore host can read it as part of the monthly rehearsal.

Best practices

Security notes

Cost & sizing

The bill has two dominant components — S3 storage and S3 requests — plus small charges for KMS and (if used) NAT/VPC-endpoint egress. The levers:

Rough monthly figures for a mid-size cluster (a few hundred GB, hourly incrementals, weekly fulls, 4 weeks retention):

Cost driver What you pay for Rough INR / month Lever to reduce it
S3 standard storage (compressed backups + WAL) ~150–250 GB stored ~₹500–900 zst, incrementals, retention, Glacier for cold fulls
S3 PUT requests (WAL + backup files) Thousands of PUTs/day ~₹200–600 repo1-bundle=y, async batching
S3 GET requests (restores + monthly verify/rehearsal) Occasional bulk reads ~₹100–400 Rehearse monthly not daily; --delta
KMS requests (SSE-KMS) Encrypt/decrypt operations ~₹100–300 bucket_key_enabled = true
VPC endpoint / NAT egress (if routed) Per-GB backup egress ~₹200–700 S3 VPC endpoint (no NAT data charge)
Typical total ~₹1,500–3,000 (a rounding error against losing a trading day)

The whole capability costs single-digit thousands of rupees a month for a mid-size cluster — trivial against the cost of losing an hour of a payment ledger, which is the entire justification.

Interview & exam questions

1. Why can pgBackRest do point-in-time recovery but pg_dump cannot? pg_dump is a logical snapshot of one instant with no mechanism to replay forward. PITR needs a physical base backup plus an unbroken chain of WAL segments; you restore the base and replay WAL up to any target time/xid/lsn. pgBackRest takes the physical backup and archives the WAL; pg_dump captures neither the file-level base nor the WAL stream.

2. Which PostgreSQL settings enable continuous archiving, and which need a restart? wal_level (≥ replica), archive_mode = on, archive_command, and usually archive_timeout. wal_level and archive_mode require a full restart; archive_command and archive_timeout take effect on reload. The classic bug is setting all four with ALTER SYSTEM + reload and assuming PITR is on when it isn’t.

3. What does archive_timeout control and how does it relate to RPO? It forces PostgreSQL to switch to a new WAL segment (archiving the current one) at least every N seconds, so even an idle database archives regularly. It effectively caps your RPO: archive_timeout=60 means worst-case data loss is ~60 seconds, because no more than a minute of changes can sit in an un-archived segment.

4. Explain full vs differential vs incremental backups and their restore cost. A full copies everything and restores alone. A differential copies changes since the last full and restores with its full. An incremental copies changes since the last backup of any type and restores with every backup back to its full. Incrementals are smallest/fastest to take but have the longest restore chain; fulls are the opposite.

5. Walk through restoring to the second before a bad UPDATE at 02:14. Stop PostgreSQL on a recovery host, run pgbackrest --stanza=X --type=time --target="2026-06-10 02:13:30+05:30" --target-action=promote --delta restore, start PostgreSQL — it restores the base and replays WAL until 02:13:30, then promotes. Confirm with pg_is_in_recovery() returning f and a row-count check. Never run this against the live primary.

6. What is a timeline, and why take a full backup right after a PITR? When recovery promotes and the database accepts writes, PostgreSQL increments the timeline ID (the WAL filename prefix) so the new branch’s WAL never collides with the old. The new timeline has no backup baseline, so you cannot PITR on it until you take a fresh full — hence the mandatory post-recovery full.

7. What’s the difference between --target-action=pause and promote? With pause, recovery stops at the target but the database stays read-only so you can inspect before committing — then you promote (or re-target if you overshot). With promote, the database opens read-write immediately and forks the timeline. pause is the safer choice for high-stakes restores where you want to validate first.

8. How do repo1-retention-full and repo1-retention-archive differ, and why does it matter? retention-full keeps N full backups; retention-archive controls how far back the continuous WAL (needed for to-the-second PITR between backups) is kept. You can keep four fulls but only PITR-capable WAL for the two newest if retention-archive=2 — the older fulls are restorable only to their exact backup point. Confirm this matches your policy.

9. Why use pgbackrest archive-push instead of a scripted aws s3 cp in archive_command? archive-push returns correct exit codes (non-zero on failure so PG retains and retries), is idempotent and checksum-aware (safe under retries, errors loudly on same-name/different-content WAL), supports async parallel upload, and integrates with retention. A bare aws s3 cp can silently return 0 without uploading, leaving a hole in the chain.

10. What does S3 Object Lock protect against, and what’s the GOVERNANCE/COMPLIANCE difference? Object Lock applies WORM retention so backups can’t be deleted within the window — the key defense against ransomware that targets backups. GOVERNANCE can be overridden by a principal with s3:BypassGovernanceRetention (break-glass); COMPLIANCE cannot be removed by anyone, including root, until expiry. It requires versioning.

11. A restore lands six hours off the intended time. What’s the likely cause and fix? A naive --target timestamp interpreted in the wrong timezone (e.g. a UTC server reading an IST-intended time). Always pass an explicit offset (+05:30) or standardise on UTC. The server log’s “recovery stopping … time” line confirms which timezone was applied.

12. How do you verify the backup system is actually working? Three levels: check (forces a WAL switch and confirms it reached the repo — archiving health), verify (checksums every backup/WAL file against the manifest — integrity), and a real restore rehearsal to a scratch host (the only proof PostgreSQL starts and the data is correct). Run all three on a schedule; the rehearsal monthly and ticketed.

These map to the PostgreSQL DBA skill set (associate/professional certification programs cover WAL archiving, PITR, pgBackRest), the AWS Certified Database – Specialty and Solutions Architect exams (S3 durability, Object Lock, KMS, backup/restore strategy), and general SRE/DevOps disaster-recovery competency (RPO/RTO design, immutable backups). A compact mapping:

Question theme Primary domain Objective area
WAL archiving, archive_command, restart-vs-reload PostgreSQL DBA Backup & recovery configuration
Full/diff/incr, restore chains, timelines PostgreSQL DBA PITR operations
S3 Object Lock, SSE-KMS, lifecycle, IAM scoping AWS Database / SAA Data protection & security
RPO/RTO design, retention math SRE / DevOps DR planning
Encryption, immutability, audited restores Security / compliance Controls & governance

Quick check

  1. You set wal_level, archive_mode, archive_command, and archive_timeout with ALTER SYSTEM and ran SELECT pg_reload_conf();, but a later restore can only reach backup boundaries. What did you miss?
  2. A user reports a runaway DELETE committed at 14:22:10. Which --type and --target (with what one habit) do you use to recover to just before it, and what flag stops recovery cleanly at the target?
  3. True or false: after a successful PITR you can keep using the same most-recent full backup as the baseline for future point-in-time recoveries on the recovered database.
  4. Your pg_wal/ directory is steadily filling and the disk is nearly full. What single view tells you whether archiving is failing, and what column do you read?
  5. You configured repo1-cipher-type=aes-256-cbc. During a real restore on a fresh host, pgBackRest can’t decrypt the backup. What’s the cause and the fix?

Answers

  1. You reloaded instead of restarting. wal_level and archive_mode only take effect on a full restart; a reload silently ignores them, so archiving never actually started and there’s no WAL between backups. Restart PostgreSQL, then run pgbackrest check to prove the path works.
  2. Use --type=time --target="2026-06-10 14:22:00+05:30" — and the habit is always include the timezone offset (or run in UTC) so you don’t land hours off. Add --target-action=promote (or pause to inspect first) so recovery stops cleanly at the target instead of replaying to the end of WAL.
  3. False. Promoting after a PITR forks a new timeline that has no backup baseline of its own. You must take a fresh --type=full backup immediately; the old full belongs to the previous timeline and cannot serve as the floor for PITR on the new one.
  4. pg_stat_archiver. Read last_failed_wal (and last_failed_time) — any non-NULL value means archiving is failing, so PostgreSQL is correctly retaining un-archived WAL in pg_wal/, which is filling the disk. Fix the cause pgBackRest logs and the queue drains.
  5. The restore host doesn’t have the correct repo1-cipher-pass (the PGBACKREST_REPO_CIPHER environment variable is unset or wrong). Client-side encryption is non-recoverable without the exact passphrase. Supply it from your secrets manager; store it as the single source of truth and back it up out-of-band so this never blocks a real recovery.

Glossary

Next steps

You can now build PITR end to end, restore to any instant, and operate it safely. Build outward:

PostgreSQLpgBackRestS3PITRWAL ArchivingBackupDisaster RecoveryObject Lock
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading