Backup, Recovery & Disaster Planning
Protecting your data from hardware failures and disasters.
When Disaster Strikes: Why Backups Are Your Lifeline
In 2017, GitLab accidentally deleted 300GB of production database data. Their backup system had failed silently for months. The recovery took 18 hours and they lost 6 hours of user data. In 2021, a pair of vulnerabilities in Western Digital's My Book Live NAS devices (including CVE-2021-35941, an unauthenticated factory-reset endpoint) let attackers wipe devices over the internet with no login required; owners who used the NAS itself as their only backup location lost everything it held, in one stroke. In 2021, a Facebook maintenance command mistakenly disconnected its entire backbone network; its own DNS servers, seeing the backbone as unreachable, withdrew their BGP route advertisements as designed, taking Facebook, Instagram, and WhatsApp offline for about 7 hours. Recovery was slow not because failover was untested, but because the outage also knocked out the internal tools engineers relied on to diagnose it, and the data centers' physical security controls (deliberately hard to bypass) meant sending people on-site took real time. Backups aren't about if disaster strikes, it's about when. Hardware fails (disk crashes, data center fires), humans make mistakes (DELETE without WHERE, DROP TABLE accidents), and attackers encrypt data with ransomware. This lesson coversbackup strategies (full, incremental, differential backups with trade-offs), Point-in-Time Recovery (PITR) to restore to exact moments before corruption, RPO/RTO concepts (how much data loss is acceptable, how fast recovery must be), testing procedures (untested backups = no backups), anddisaster recovery planning for multi-region failover. It is worth keeping durability and availability straight while you read: Amazon S3 Standard is designed for 99.999999999% (eleven nines) of durability, but its availability SLA is 99.9%. Durable storage you cannot reach right now is still an outage.
Try it locally (PostgreSQL 16)
You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.
# Plain PostgreSQL 16 is all this lesson needs. docker run -d --name pg-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ -p 5432:5432 postgres:16-alpine docker exec -it pg-demo psql -U demo -d demo # Clean up: docker rm -f pg-demo
Then seed some realistic data, since every backup size, restore, and recovery result below was measured against it:
-- Seed data used for every backup example below: a small but
-- non-trivial dataset (5,000 users, 200,000 transactions) so backup
-- sizes and restore behavior are real, not hand-waved.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE,
email VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
amount NUMERIC(10,2),
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO users (username, email)
SELECT 'user_' || i, 'user_' || i || '@example.com'
FROM generate_series(1, 5000) AS i;
INSERT INTO transactions (user_id, amount, description)
SELECT (i % 5000) + 1, ((i * 37) % 50000)::numeric / 100,
'Transaction number ' || i || ' - demo padding text for realistic row size.'
FROM generate_series(1, 200000) AS i;RPO & RTO: Defining Your Recovery Requirements
Before choosing a backup strategy, you must define acceptable data loss and downtime. These metrics drive all technical decisions and costs.
RPO: Recovery Point Objective
How much data can you afford to lose?
Example: RPO = 1 hour
Disaster occurs at 3:00 PM. You can restore to 2:00 PM backup. You lose 1 hour of transactions (2:00 PM - 3:00 PM).
- RPO = 0 minutes → Real-time replication needed
- RPO = 1 hour → Hourly backups sufficient
- RPO = 24 hours → Daily backups acceptable
RTO: Recovery Time Objective
How fast must you restore service?
Example: RTO = 4 hours
Disaster occurs at 3:00 PM. You must have database operational by 7:00 PM. Includes detection, decision, restore, and validation time.
- RTO = 5 minutes → Hot standby required
- RTO = 1 hour → Warm standby + fast restore
- RTO = 24 hours → Cold backup, manual restore OK
The Cost-Recovery Trade-off
| Scenario | RPO | RTO | Cost | Solution |
|---|---|---|---|---|
| E-commerce (peak season) | 0 min | 5 min | $$$$$ | Multi-region active-active |
| SaaS app | 5 min | 1 hour | $$$ | Streaming replication + backups |
| Internal tools | 1 hour | 4 hours | $$ | Hourly backups + snapshots |
| Analytics DB | 24 hours | 24 hours | $ | Daily backups to S3 |
Backup Strategies: Full, Incremental, Differential
Different backup types balance storage costs, backup speed, and restore complexity. Most production systems use a combination.
1. Full Backup: Complete Database Copy
✅ Advantages
- Fastest restore (single file)
- Simple to manage
- Self-contained
❌ Disadvantages
- Slowest backup time
- Most storage space
- High network bandwidth
# PostgreSQL Full Backup with pg_dump
import subprocess
from datetime import datetime
from pathlib import Path
# pg_dump is a client binary, not part of psycopg, and it must exist on the
# machine running this script. With PostgreSQL in Docker it lives inside the
# container, so a bare pg_dump call fails with
# "FileNotFoundError: [Errno 2] No such file or directory: 'pg_dump'".
# Two ways out: install the client (apt install postgresql-client-16), or
# run the binary through docker exec, which is what this does so the
# example needs no host install.
CONTAINER = "pg-demo"
def full_backup_postgres(db_name, backup_dir):
backup_dir = Path(backup_dir)
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# -F c is a compressed binary archive, not SQL text, so ".dump" is the
# honest extension. Only pg_restore reads it; psql cannot.
backup_file = backup_dir / f"{db_name}_full_{timestamp}.dump"
cmd = [
'docker', 'exec',
'-e', 'PGPASSWORD=demo', # pg_dump never prompts under subprocess
CONTAINER,
'pg_dump',
'-U', 'demo',
'-d', db_name,
'-F', 'c', # Custom format (compressed)
]
# No text=True: the custom format is binary and decoding it corrupts it.
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
print(f"Backup failed: {result.stderr.decode().strip()}")
return None
backup_file.write_bytes(result.stdout)
size_mb = backup_file.stat().st_size / (1024 * 1024)
print(f"Full backup completed: {backup_file}")
print(f" Size: {size_mb:.2f} MB")
return backup_file
# Usage
backup_file = full_backup_postgres('demo', './backups')Expected Output:
Full backup completed: backups/demo_full_20260801_104018.dump Size: 1.96 MB
1.96 MB against the 5,000-user, 200,000-transaction seed above. The detail that trips people up is that pg_dump is a client binary rather than anything psycopg provides: it has to exist on the machine running the script. With PostgreSQL in Docker it does not, and you get FileNotFoundError: No such file or directory: 'pg_dump' before a single byte is read. Running it through docker exec sidesteps that with no host install; on a real backup host you would install postgresql-client and drop the docker prefix. Two smaller things matter as much. -F c produces a compressed binary archive, not SQL, so the file is named .dump and only pg_restore can read it, never psql. And the dump is streamed as bytes with no text=True, because decoding a binary archive as UTF-8 corrupts it.
2. Incremental Backup: Only Changes Since Last Backup
✅ Advantages
- Fastest backup time
- Minimal storage
- Low network impact
❌ Disadvantages
- Slowest restore (needs full + all incrementals)
- Complex chain management
- Single broken link = unusable
Incremental Backup Chain
Sunday: FULL BACKUP (100 GB) Monday: Incremental (5 GB) ← Changes since Sunday Tuesday: Incremental (7 GB) ← Changes since Monday Wednesday: Incremental (6 GB) ← Changes since Tuesday Thursday: Incremental (8 GB) ← Changes since Wednesday To restore Wednesday: Need FULL + Mon + Tue + Wed (118 GB total)
The 100 GB/5 GB figures above are a production-scale illustration of the chain concept, not a local measurement. At the small scale of the local demo dataset, the measured numbers below tell a more nuanced story.
One warning before the code: running pg_basebackup a second time is not an incremental backup. It copies the entire data directory, at full size, every time. Two consecutive runs here with zero changes in between produced archives of 8,054,246 and 8,054,162 bytes, the same ~7.68 MB, not "just the changes". PostgreSQL 17 added a true block-level --incrementalflag to pg_basebackup; PostgreSQL 16, used in this lesson, has no such option.
The real incremental mechanism is continuous WAL archiving: every completed WAL segment (16 MB by default) is copied to an archive directory as it fills, driven by the archive_command setting. Restoring means extracting the last base backup and replaying every archived segment since. The script below sets that up, writes 3,000 new transactions, and reports exactly what landed in the archive: that difference is the incremental backup, with no secondpg_basebackup run involved.
# WAL-based incremental backup, start to finish. This block turns on
# archiving, generates traffic, and measures the result on its own: it
# needs only the pg-demo container and the seed data from the top of the
# lesson.
import subprocess
import time
CONTAINER = "pg-demo"
WAL_ARCHIVE = "/var/lib/postgresql/wal_archive"
def psql(sql):
result = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=demo", CONTAINER,
"psql", "-U", "demo", "-d", "demo", "-tAc", sql],
capture_output=True, text=True, check=True)
return result.stdout.strip()
def in_container(*args):
# -u postgres matters: docker exec defaults to root, and a root-owned
# archive directory makes every archive_command fail with
# "cp: can't create ...: Permission denied".
result = subprocess.run(["docker", "exec", "-u", "postgres", CONTAINER, *args],
capture_output=True, text=True, check=True)
return result.stdout
def switch_and_wait():
"""Close the current WAL segment and block until it reaches the archive."""
closed = psql("SELECT pg_walfile_name(pg_switch_wal())")
for _ in range(30):
archived = psql("SELECT coalesce(last_archived_wal, '') FROM pg_stat_archiver")
if archived >= closed:
return closed
time.sleep(1)
raise RuntimeError(f"segment {closed} was never archived")
def archive_snapshot():
"""Map every archived segment to its size in bytes."""
out = in_container("sh", "-c", f"stat -c '%s %n' {WAL_ARCHIVE}/* 2>/dev/null || true")
snapshot = {}
for line in out.splitlines():
size, path = line.split(" ", 1)
snapshot[path.rsplit("/", 1)[-1]] = int(size)
return snapshot
def incremental_backup_size(before, after):
new_files = sorted(set(after) - set(before))
total_bytes = sum(after[f] for f in new_files)
print(f"WAL segments archived: {len(new_files)}")
print(f"Raw size: {total_bytes / (1024 * 1024):.2f} MB")
return new_files, total_bytes
# 1. Turn on continuous WAL archiving. archive_mode only takes effect on
# restart; archive_command on its own would just need a reload.
in_container("mkdir", "-p", WAL_ARCHIVE)
psql("ALTER SYSTEM SET wal_level = replica")
psql("ALTER SYSTEM SET archive_mode = on")
psql("ALTER SYSTEM SET archive_command = "
f"'test ! -f {WAL_ARCHIVE}/%f && cp %p {WAL_ARCHIVE}/%f'")
subprocess.run(["docker", "restart", CONTAINER], capture_output=True, check=True)
for _ in range(30):
if subprocess.run(["docker", "exec", CONTAINER, "pg_isready", "-U", "demo"],
capture_output=True).returncode == 0:
break
time.sleep(1)
else:
raise RuntimeError("PostgreSQL did not come back up")
print("archive_mode:", psql("SHOW archive_mode"))
# 2. Baseline: flush anything already pending, then snapshot the archive.
switch_and_wait()
before = archive_snapshot()
# 3. Do some work: 3,000 new transactions on top of the 200,000-row seed.
psql("""INSERT INTO transactions (user_id, amount, description)
SELECT (i % 5000) + 1, ((i * 91) % 50000)::numeric / 100,
'Incremental demo row ' || i
FROM generate_series(1, 3000) AS i""")
switch_and_wait()
# 4. Whatever is new in the archive IS the incremental backup.
after = archive_snapshot()
files, total_bytes = incremental_backup_size(before, after)
Expected Output:
archive_mode: on WAL segments archived: 2 Raw size: 32.00 MB
Compressing those two segments with gzip brings them to 3,273,178 bytes (~3.12 MB), still larger than the entire 1.96 MB full backup from a moment ago. That is not a mistake: WAL segments are fixed at 16 MB regardless of how much they actually hold, and PostgreSQL writes a full 8 KB copy of every page the first time it is touched after a checkpoint (full_page_writes), so a small, low-traffic demo database can make WAL archiving look less space-efficient than a full dump. Expect the compressed figure to move by a few hundred kilobytes between runs, since it depends on where the checkpoints fall; the 32.00 MB raw total is stable because segment size is fixed. At production scale, where a full backup is tens or hundreds of gigabytes and a day's changes touch a small fraction of that, the asymmetry runs the other way, which is why WAL archiving (not repeated full copies) is the standard incremental strategy. Purpose-built tools like pgBackRest and WAL-G compress and deduplicate archived WAL further than a plain cparchive_command does.
3. Differential Backup: Changes Since Last Full Backup
✅ Advantages
- Faster restore than incremental (only FULL + latest DIFF)
- Simpler chain management
- Balance of speed and simplicity
❌ Disadvantages
- Grows larger each day
- More storage than incremental
- Backup time increases daily
Differential Backup Chain
Sunday: FULL BACKUP (100 GB) Monday: Differential (5 GB) ← Changes since Sunday Tuesday: Differential (12 GB) ← Changes since Sunday (not Monday!) Wednesday: Differential (18 GB) ← Changes since Sunday Thursday: Differential (26 GB) ← Changes since Sunday To restore Wednesday: Only need FULL + Wed (118 GB total) Faster restore than incremental!
Recommended Strategy: Hybrid Approach
Most production systems use a combination:
- Weekly: Full backup (Sundays at 2 AM)
- Daily: Differential backup (Mon-Sat at 2 AM)
- Hourly: Incremental WAL archiving (for PITR)
- Real-time: Streaming replication to standby (for HA)
Point-in-Time Recovery (PITR)
PITR allows you to restore your database to any specific moment in time, not just when backups ran. Critical for recovering from human errors ("I just deleted all orders!").
How PITR Works
PITR uses WAL (Write-Ahead Logs) which record every database change. By replaying WAL files on top of a base backup, you can reconstruct database state at any point in time.
Timeline: ┌─────────────┬──────────────────────────────────┬─────────────┐ │ Base │ WAL Files (changes) │ Disaster │ │ Backup │ │ Occurs │ └─────────────┴──────────────────────────────────┴─────────────┘ 2:00 AM 3:47 PM Sunday Tuesday PITR Process: 1. Restore base backup (Sunday 2:00 AM) 2. Replay WAL files up to Tuesday 3:46 PM (1 minute before disaster) 3. Database state = exactly as it was at 3:46 PM 4. You've avoided the corruption that happened at 3:47 PM!
Step 1: Configure PostgreSQL for WAL Archiving
# postgresql.conf settings for PITR # Enable WAL archiving wal_level = replica # Generate enough WAL for PITR archive_mode = on # Enable archiving (restart required) # Copy each completed segment to the archive. The "test ! -f" guard makes # the command refuse to overwrite an existing segment: without it a # misconfigured server can silently clobber archived WAL and break recovery. archive_command = 'test ! -f /var/lib/postgresql/wal_archive/%f && cp %p /var/lib/postgresql/wal_archive/%f' # WAL retention (keep enough for recovery) wal_keep_size = 1GB # Keep at least 1GB of WAL max_wal_senders = 3 # Allow 3 streaming replicas # Checkpoint settings (affects recovery time) checkpoint_timeout = 5min # Checkpoint every 5 minutes max_wal_size = 1GB # On a normal install, edit postgresql.conf and reload: # sudo systemctl reload postgresql # archive_mode is the exception: it needs a full restart, not a reload.
/var/lib/postgresql/wal_archive/, which is what makes PITR possible. These are the same settings the incremental backup script applied earlier through ALTER SYSTEM: if you already ran that block, archiving is on and there is nothing to change here. This is what it looks like written directly into postgresql.conf instead.Step 2: Create Base Backup for PITR
host all all all scram-sha-256 rule does not cover the replication pseudo-database, so there is no rule at all for the host machine's address arriving over the mapped port. Pointing pg_basebackup at the published port from outside fails with no pg_hba.conf entry for replication connection from host "172.17.0.1". Routing the command through docker exec makes the connection originate inside the container, where it is trusted. The plain psql/pg_dump/pg_restore examples elsewhere in this lesson are regular connections and are unaffected, only pg_basebackup's replication protocol is.# PITR base backup. Like the pg_dump script, this runs pg_basebackup
# through docker exec, so nothing has to be installed on the host. Here
# there is a second reason to go through the container: see the note below.
import subprocess
from datetime import datetime
CONTAINER = "pg-demo"
# A path inside the container, owned by postgres. pg_basebackup writes a
# directory, not a single file, so it cannot be streamed to stdout the way
# the pg_dump archive was.
BACKUP_DIR = "/var/lib/postgresql/backups"
def create_pitr_base_backup():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{BACKUP_DIR}/base_{timestamp}"
cmd = [
"docker", "exec", "-u", "postgres", "-e", "PGPASSWORD=demo", CONTAINER,
"pg_basebackup",
"-h", "localhost",
"-U", "demo",
"-D", backup_path,
"-Ft", # Tar format
"-z", # Compress
"-P", # Show progress
"--wal-method=stream", # Include the WAL generated during the copy
"--checkpoint=fast", # Force an immediate checkpoint
]
print("Creating PITR base backup...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Backup failed: {result.stderr.strip()}")
return None
print(f"Base backup created: {backup_path}")
print(" Includes WAL files for PITR")
return backup_path
backup_path = create_pitr_base_backup()
Expected Output:
Creating PITR base backup... Base backup created: /var/lib/postgresql/backups/base_20260801_111849 Includes WAL files for PITR
The backup lands inside the container as three files: base.tar.gz (the data directory, 4.25 MB for this dataset), pg_wal.tar.gz (the WAL streamed during the copy), and backup_manifest (checksums that pg_verifybackup can check). To keep a copy on the host, pull the directory out afterwards with docker cp pg-demo:/var/lib/postgresql/backups/base_<timestamp> ./. In production, that copy belongs on separate hardware or in object storage: a backup stored only on the machine it protects is not a backup.
Step 3: Perform Point-in-Time Recovery
This was run end to end against a smaller 500-user, 10,000-transaction table so the timeline is easy to follow. First there has to be something to recover from, so the block below inserts a marker row, notes the time, and then wipes the table with an unqualified DELETE. It prints the timestamp the restore will rewind to: yours will differ, so copy it from your own run.
# Simulate the disaster PITR will undo, and capture the timestamp to
# rewind to. Run this after the base backup, before the restore.
import subprocess
import time
from pathlib import Path
CONTAINER = "pg-demo"
TARGET_FILE = Path("pitr_target.txt")
def psql(sql):
result = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=demo", CONTAINER,
"psql", "-U", "demo", "-d", "demo", "-tAc", sql],
capture_output=True, text=True, check=True)
return result.stdout.strip()
# 1. A row worth saving, inserted just before things go wrong.
psql("""INSERT INTO transactions (user_id, amount, description)
VALUES (1, 99.99, 'SAFE MARKER - before disaster')""")
print("Rows before disaster:", psql("SELECT count(*) FROM transactions"))
# 2. The moment to rewind to. Read it from the server, not the host clock:
# recovery_target_time is compared against timestamps the server wrote.
# Saved to a file so the restore step picks up YOUR timestamp instead of
# one copied out of this page.
time.sleep(2)
target_time = psql("SELECT now()")
TARGET_FILE.write_text(target_time)
print("Recovery target:", target_time)
print(f" saved to {TARGET_FILE}")
time.sleep(2)
# 3. The disaster: someone forgets the WHERE clause.
psql("DELETE FROM transactions")
print("Rows after disaster:", psql("SELECT count(*) FROM transactions"))
# 4. Recovery can only replay what reached the archive, and the segment
# holding these changes is still being written. Force it out.
psql("SELECT pg_switch_wal()")
time.sleep(2)
print("Last archived segment:", psql("SELECT last_archived_wal FROM pg_stat_archiver"))
Expected Output:
Rows before disaster: 10001 Recovery target: 2026-08-01 16:19:00.606427+00 saved to pitr_target.txt Rows after disaster: 0 Last archived segment: 000000010000000000000004
Now the recovery itself. Neither input is a literal: the base backup is discovered by latest_base_backup(), and the target is read back from pitr_target.txt. Both would otherwise be values that only meant something on the machine that wrote this page.
SELECT count(*) still returns plenty, and the log shows recovery stopping before commit at a transaction earlier than the disaster. If that happens, check the newest surviving row with SELECT max(created_at) FROM transactions;. If it predates your marker insert, the target was too early: remove the restore directory and rerun with the right timestamp.# Perform PITR. The recovered data directory is built inside the
# container, next to the one still running, so every filesystem step goes
# through docker exec.
import subprocess
from pathlib import Path
CONTAINER = "pg-demo"
BACKUP_DIR = "/var/lib/postgresql/backups"
def in_container(*args):
result = subprocess.run(["docker", "exec", "-u", "postgres", CONTAINER, *args],
capture_output=True, text=True)
if result.returncode != 0:
# Worth doing by hand rather than with check=True: that raises a bare
# CalledProcessError and throws away the message that explains it,
# such as "tar: can't open ...: No such file or directory".
raise RuntimeError(f"{args[0]} failed: {result.stderr.strip()}")
return result.stdout
def latest_base_backup():
"""Newest base_* directory. The timestamped names sort chronologically."""
listing = in_container("sh", "-c", f"ls -d {BACKUP_DIR}/base_* 2>/dev/null || true")
backups = sorted(listing.split())
if not backups:
raise RuntimeError(f"no base backup in {BACKUP_DIR}: run the previous step first")
return backups[-1]
def perform_pitr(base_backup_path, wal_archive_dir, target_time, restore_dir):
"""Restore the database to a specific point in time.
base_backup_path: directory holding base.tar.gz, from the previous step
wal_archive_dir: directory the archive_command has been filling
target_time: timestamp to stop recovery at
restore_dir: where the recovered data directory is built
"""
print(f"Starting PITR to {target_time}...")
print(f"Using base backup: {base_backup_path}")
print("Step 1: Extracting base backup...")
# Recovery has to start from an empty directory. Leftovers from an
# earlier attempt mix old and new files, and PostgreSQL either refuses
# to start or recovers something you did not intend.
if in_container("sh", "-c", f"ls -A {restore_dir} 2>/dev/null || true").strip():
raise RuntimeError(
f"{restore_dir} is not empty. Remove it first:\n"
f" docker exec -u postgres {CONTAINER} rm -rf {restore_dir}")
in_container("mkdir", "-p", restore_dir)
in_container("tar", "-xzf", f"{base_backup_path}/base.tar.gz", "-C", restore_dir)
# PostgreSQL refuses to start on a data directory with the wrong
# permissions. tar preserves the permissions of files INSIDE the
# archive, but not of the extraction target itself, so skipping this
# gives "FATAL: data directory ... has invalid permissions".
in_container("chmod", "700", restore_dir)
print("Step 2: Writing recovery configuration...")
recovery_conf = (
f"restore_command = 'cp {wal_archive_dir}/%f %p'\n"
f"recovery_target_time = '{target_time}'\n"
f"recovery_target_action = 'promote'\n"
)
# recovery.signal is what puts the instance into recovery mode at all.
in_container("touch", f"{restore_dir}/recovery.signal")
in_container("sh", "-c",
f"cat >> {restore_dir}/postgresql.auto.conf <<'CONF'\n{recovery_conf}CONF")
print("PITR configuration created")
print(f" Target time: {target_time}")
print(f" Restore directory: {restore_dir}")
print("\n Start PostgreSQL on this data directory to complete recovery")
return restore_dir
# Usage: restore to just before the disaster. Neither value is hardcoded:
# the base backup is discovered, and the target comes from the file the
# previous step wrote. In a real incident you would type the timestamp of
# the moment before the bad query instead.
restore_dir = perform_pitr(
base_backup_path=latest_base_backup(),
wal_archive_dir="/var/lib/postgresql/wal_archive",
target_time=Path("pitr_target.txt").read_text().strip(),
restore_dir="/var/lib/postgresql/restored",
)
Expected Output:
Starting PITR to 2026-08-01 16:19:00.606427+00... Using base backup: /var/lib/postgresql/backups/base_20260801_111849 Step 1: Extracting base backup... Step 2: Writing recovery configuration... PITR configuration created Target time: 2026-08-01 16:19:00.606427+00 Restore directory: /var/lib/postgresql/restored Start PostgreSQL on this data directory to complete recovery
Note that the script only stages the recovery: it extracts the data directory and writes the configuration, but replays nothing. WAL replay happens when a postmaster starts on that directory, so launch a second one on a spare port and query it.
# The script only stages the recovery. Starting a second postmaster on # the restored directory, on a spare port, is what actually replays WAL. docker exec -u postgres -d pg-demo \ sh -c 'postgres -D /var/lib/postgresql/restored -p 5433 > /tmp/restore.log 2>&1' docker exec pg-demo cat /tmp/restore.log # Then query the recovered instance on port 5433: docker exec -e PGPASSWORD=demo pg-demo psql -U demo -d demo -p 5433 \ -c "SELECT count(*) FROM transactions;" \ -c "SELECT id, description FROM transactions WHERE description LIKE 'SAFE MARKER%';"
Expected Output:
LOG: database system was interrupted; last known up at 2026-08-01 16:18:49 UTC LOG: starting point-in-time recovery to 2026-08-01 16:19:00.606427+00 LOG: starting backup recovery with redo LSN 0/3000028, checkpoint LSN 0/3000060, on timeline ID 1 LOG: restored log file "000000010000000000000003" from archive LOG: redo starts at 0/3000028 LOG: restored log file "000000010000000000000004" from archive LOG: completed backup recovery with redo LSN 0/3000028 and end LSN 0/3000100 LOG: consistent recovery state reached at 0/3000100 LOG: database system is ready to accept read-only connections LOG: recovery stopping before commit of transaction 738, time 2026-08-01 16:19:02.679686+00 LOG: last completed transaction was at log time 2026-08-01 16:18:58.464924+00 LOG: selected new timeline ID: 2 LOG: archive recovery complete LOG: database system is ready to accept connections count ------- 10001 (1 row) id | description -------+------------------------------- 13001 | SAFE MARKER - before disaster (1 row)
The marker row inserted right before the disaster survived, and all 10,001 rows are back. The DELETE never gets replayed: its transaction committed at 16:19:02.679, and the log line recovery stopping before commit of transaction 738 shows recovery halting ahead of it, at the 16:19:00.606 target. The restored log file ... from archive lines are the restore_command at work, pulling segments back out of the archive one at a time. Promotion then opens a new timeline (ID 2), which is how PostgreSQL keeps the recovered history from colliding with the original one still running on timeline 1. That is also why you can restore repeatedly to different target times from one base backup without the attempts overwriting each other.
Testing Backups: The Only Backup That Matters
Untested backups are useless. You only discover corrupt backups when you try to restore. Regular testing ensures your backup system actually works.
Automated Backup Testing
# Automated backup verification. Install: pip install "psycopg[binary]"
import subprocess
from datetime import datetime
from pathlib import Path
import psycopg
CONTAINER = "pg-demo"
BACKUP_DIR = Path("./backups")
ADMIN_DSN = "host=localhost port=5432 dbname=postgres user=demo password=demo"
EXPECTED_TABLES = ("users", "transactions")
def latest_backup():
"""Newest archive in the backup directory: the names sort chronologically."""
dumps = sorted(BACKUP_DIR.glob("*.dump"))
if not dumps:
raise RuntimeError(f"no .dump file in {BACKUP_DIR}: run the full backup script first")
return dumps[-1]
def test_backup_restore(backup_file, test_db_name="test_restore_db"):
"""Test a backup by actually restoring it and verifying the data."""
print(f"Testing backup: {backup_file}")
start_time = datetime.now()
# Step 1: Recreate the scratch database. CREATE/DROP DATABASE cannot run
# inside a transaction, hence autocommit.
print("Step 1: Cleaning up old test database...")
with psycopg.connect(ADMIN_DSN, autocommit=True) as conn:
conn.execute(f"DROP DATABASE IF EXISTS {test_db_name}")
conn.execute(f"CREATE DATABASE {test_db_name}")
# Step 2: Restore into it. The archive sits on the host and pg_restore
# lives in the container, so feed it over stdin: "docker exec -i" plus
# no filename argument makes pg_restore read from standard input.
print("Step 2: Restoring backup...")
cmd = [
"docker", "exec", "-i", "-e", "PGPASSWORD=demo", CONTAINER,
"pg_restore", "-U", "demo", "-d", test_db_name,
]
# Stream the archive rather than read_bytes() it: production dumps are
# measured in gigabytes and do not belong in memory.
with open(backup_file, "rb") as archive:
result = subprocess.run(cmd, stdin=archive, capture_output=True)
if result.returncode != 0:
print(f"Restore failed: {result.stderr.decode().strip()}")
return False
# Step 3: Verify the data actually arrived, not just that the command
# exited 0. A backup that restores an empty schema still "succeeds".
print("Step 3: Verifying data...")
test_dsn = ADMIN_DSN.replace("dbname=postgres", f"dbname={test_db_name}")
with psycopg.connect(test_dsn) as conn:
found = {row[0] for row in conn.execute("""
SELECT tablename FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
""").fetchall()}
print(f" Found {len(found)} tables: {', '.join(sorted(found))}")
# Check the tables exist before counting them. Querying a missing
# table would raise UndefinedTable and crash the checker instead of
# reporting the backup as bad, which is the one job it has.
missing = [t for t in EXPECTED_TABLES if t not in found]
if missing:
print(f"Verification FAILED: missing tables: {', '.join(missing)}")
return False
for table in EXPECTED_TABLES:
count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table} table: {count} rows")
if count == 0:
print(f"Verification FAILED: {table} restored empty")
return False
# Step 4: Cleanup
print("Step 4: Cleaning up test database...")
with psycopg.connect(ADMIN_DSN, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {test_db_name}")
elapsed = (datetime.now() - start_time).total_seconds()
print(f"Backup test PASSED in {elapsed:.2f}s")
print(" Backup is valid and restorable!")
return True
# Usage: run weekly backup verification against the newest archive the full
# backup script produced.
test_backup_restore(latest_backup())
Expected Output:
Testing backup: backups/demo_full_20260801_112706.dump Step 1: Cleaning up old test database... Step 2: Restoring backup... Step 3: Verifying data... Found 2 tables: transactions, users users table: 5000 rows transactions table: 200000 rows Step 4: Cleaning up test database... Backup test PASSED in 0.55s Backup is valid and restorable!
Run against the full backup taken earlier, this restores 5,000 users and 200,000 transactions into a scratch database and drops it again. The row counts are the part that matters: a restore that exits 0 but produces an empty schema still counts as "success" to pg_restore, so checking the exit code alone proves nothing. That is why the script fails loudly on a missing table or a zero-row table rather than reporting PASS. The elapsed time depends entirely on data volume and hardware, and half a second against a 1.96 MB backup says nothing about a production restore; a real database will take proportionally longer, which is exactly why you schedule this as an automated weekly job instead of trusting that backups "probably work."
Two details worth copying into your own version. The archive is streamed with stdin=archive rather than loaded with read_bytes(), because a production dump measured in gigabytes has no business sitting in memory. And latest_backup() picks the newest .dump by name, which works only because the timestamp format (%Y%m%d_%H%M%S) sorts chronologically as text: that is the whole reason to prefer it over a friendlier format like %d-%m-%Y.
Backup Testing Checklist
- Weekly: Automated restore test to verify backup integrity
- Monthly: Full disaster recovery drill (restore to production-like environment)
- Quarterly: PITR test (restore to specific timestamp)
- After major changes: Test backups after schema changes or upgrades
- Monitor: Alert on backup failures, verify completion times
Disaster Recovery Planning
Disaster recovery (DR) goes beyond backups: it's a comprehensive plan for handling catastrophic failures (data center fires, regional outages, ransomware).
Disaster Recovery Tiers
| Tier | Description | RTO | RPO | Cost |
|---|---|---|---|---|
| Tier 0: No DR | Backups on same site. Hope for the best. | Days-Weeks | 24+ hours | $ |
| Tier 1: Cold Site | Backups in remote location. Manual restore. | 12-24 hours | 12-24 hours | $$ |
| Tier 2: Warm Site | Standby database with delayed replication. | 1-4 hours | 1-4 hours | $$$ |
| Tier 3: Hot Site | Real-time replica, ready for failover. | 5-30 min | 0-5 min | $$$$ |
| Tier 4: Active-Active | Multi-region, serving traffic simultaneously. | 0-5 min | 0 min | $$$$$ |
Complete DR Plan Components
Documentation
- Recovery procedures (step-by-step)
- Contact list (on-call, vendors)
- System architecture diagrams
- Backup locations and credentials
- Dependencies and service order
Infrastructure
- Secondary data center/region
- Network failover configuration
- DNS failover (low TTL)
- Load balancer health checks
- Monitoring and alerting
Team Preparation
- Regular DR drills (quarterly)
- Incident response training
- Clear escalation procedures
- Communication templates
- Post-mortem process
Monitoring
- Backup success/failure alerts
- Replication lag monitoring
- Storage capacity tracking
- Recovery time metrics
- Backup integrity verification
Multi-Region DR with Streaming Replication
A DR replica in another region is a continuously updated copy of the primary, kept current by streaming WAL as it is written. Nothing here needs two regions to demonstrate: two containers on one Docker network behave identically, since the mechanism is the same whether the network hop is a millisecond or eighty.
# Two containers on a shared network stand in for the two regions. # The primary is a normal PostgreSQL container. docker network create dr-demo docker run -d --name pg-primary --network dr-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ postgres:16-alpine # The replica starts with "sleep infinity" instead of postgres on purpose: # its data directory has to be seeded from the primary's base backup before # any postmaster runs. The official image only runs initdb when the command # is "postgres", so this leaves the directory untouched. docker run -d --name pg-replica --network dr-demo postgres:16-alpine sleep infinity # Clean up: docker rm -f pg-primary pg-replica && docker network rm dr-demo
A word on the primary's configuration first, because most guides list settings you no longer need to touch. On PostgreSQL 16 wal_level = replica and max_wal_senders = 10 are already the defaults. And hot_standby, which those lists put under the primary, is a standby setting: it governs whether the standby answers read queries during recovery, and it defaults to on as well. Check rather than assume:
-- What the primary needs. On PostgreSQL 16 all three are already the -- defaults, so a stock server needs no configuration change at all: -- -- wal_level = replica -- enough WAL to feed a replica -- max_wal_senders = 10 -- concurrent replication connections -- -- hot_standby = on is a STANDBY setting, not a primary one: it controls -- whether the standby answers read queries during recovery. It is also on -- by default. Setting it on the primary does nothing. -- -- Confirm rather than assume: SHOW wal_level; SHOW max_wal_senders; SHOW hot_standby;
Expected Output:
wal_level ----------- replica (1 row) max_wal_senders ----------------- 10 (1 row) hot_standby ------------- on (1 row)
So the real work is a replication role, a slot, a firewall rule, and a base backup. Prefer a replication slot over wal_keep_size: a slot makes the primary keep WAL until this replica has actually consumed it, whereas wal_keep_size keeps a fixed amount and then discards the rest, breaking any replica that fell further behind than you guessed. The tradeoff is that an abandoned slot will grow the primary's disk without limit, so monitor pg_replication_slots and drop slots you retire.
# Set up streaming replication from pg-primary to pg-replica.
import subprocess
import time
PRIMARY = "pg-primary"
REPLICA = "pg-replica"
PGDATA = "/var/lib/postgresql/data"
SLOT = "replica1_slot"
def psql(container, sql, password="demo"):
result = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={password}", container,
"psql", "-U", "demo", "-d", "demo", "-tAc", sql],
capture_output=True, text=True, check=True)
return result.stdout.strip()
def in_container(container, *args):
result = subprocess.run(["docker", "exec", "-u", "postgres", container, *args],
capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"{args[0]} failed: {result.stderr.strip()}")
return result.stdout
# --- On the PRIMARY -------------------------------------------------
# A dedicated role with REPLICATION. It needs no rights on any table:
# physical replication ships WAL, not rows.
psql(PRIMARY, "CREATE USER replicator WITH REPLICATION LOGIN PASSWORD 'replpass'")
# A replication slot makes the primary retain WAL until this specific
# replica has consumed it. wal_keep_size only keeps a fixed amount and then
# drops the rest, which silently breaks a replica that falls behind.
psql(PRIMARY, f"SELECT pg_create_physical_replication_slot('{SLOT}')")
# The image's pg_hba.conf trusts replication only from 127.0.0.1, and its
# catch-all "host all all all" rule does NOT cover the replication
# pseudo-database. Without this line pg_basebackup is rejected.
in_container(PRIMARY, "sh", "-c",
f"echo 'host replication replicator all scram-sha-256' >> {PGDATA}/pg_hba.conf")
psql(PRIMARY, "SELECT pg_reload_conf()")
print("Primary ready: replication role, slot, and pg_hba rule in place")
# --- On the REPLICA -------------------------------------------------
# -R writes the connection settings AND creates standby.signal, the file
# that actually makes this a standby rather than an independent server.
# -S ties the replica to the slot created above.
subprocess.run(
["docker", "exec", "-u", "postgres", "-e", "PGPASSWORD=replpass", REPLICA,
"pg_basebackup", "-h", PRIMARY, "-U", "replicator", "-D", PGDATA,
"-Fp", # Plain format: this IS the replica's data directory
"-Xs", # Stream WAL alongside the copy
"-R", # Write standby.signal + primary_conninfo
"-S", SLOT],
capture_output=True, text=True, check=True)
# Same trap as the PITR restore: PostgreSQL refuses to start unless the
# data directory is 0700, and the one the image created is not.
in_container(REPLICA, "chmod", "700", PGDATA)
print("Replica seeded from base backup")
signal_file = in_container(REPLICA, "ls", f"{PGDATA}/standby.signal").strip()
print(f" {signal_file} exists -> starts in standby mode")
subprocess.run(["docker", "exec", "-u", "postgres", "-d", REPLICA,
"sh", "-c", f"postgres -D {PGDATA} > /tmp/replica.log 2>&1"],
capture_output=True, check=True)
# Wait for the primary to actually report the replica, rather than assuming.
for _ in range(30):
state = psql(PRIMARY, "SELECT coalesce(state, '') FROM pg_stat_replication")
if state == "streaming":
break
time.sleep(1)
else:
raise RuntimeError("replica never reached streaming state")
print(f"Replication state: {state}")
Expected Output:
Primary ready: replication role, slot, and pg_hba rule in place Replica seeded from base backup /var/lib/postgresql/data/standby.signal exists -> starts in standby mode Replication state: streaming
Two details in there are easy to miss. The -R flag does more than write primary_conninfo: it also creates standby.signal, an empty file whose mere presence is what makes the server start as a standby instead of an independent primary. And the data directory must be mode 0700, the same trap that bites the PITR restore, or startup fails with data directory ... has invalid permissions.
Now confirm it actually works, rather than trusting that it does: write on the primary, read it back on the replica, try to write on the replica, then fail over.
# Prove the replica is streaming, read-only, and promotable.
import subprocess
import time
PRIMARY = "pg-primary"
REPLICA = "pg-replica"
PGDATA = "/var/lib/postgresql/data"
def psql(container, sql, table=False):
# -c prints a formatted table, -tAc a bare value.
flag = "-c" if table else "-tAc"
result = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=demo", container,
"psql", "-U", "demo", "-d", "demo", flag, sql],
capture_output=True, text=True)
return (result.stdout + result.stderr).strip()
psql(PRIMARY, "CREATE TABLE dr_test (id serial primary key, note text)")
psql(PRIMARY, "INSERT INTO dr_test (note) VALUES ('written on primary')")
time.sleep(1)
print("Replica reads:", psql(REPLICA, "SELECT note FROM dr_test ORDER BY id"))
print("Replica writes:", psql(REPLICA, "INSERT INTO dr_test (note) VALUES ('nope')"))
print()
print("Primary view of the replica:")
print(psql(PRIMARY, "SELECT client_addr, state, sync_state, replay_lag "
"FROM pg_stat_replication", table=True))
print(psql(PRIMARY, "SELECT slot_name, active, wal_status "
"FROM pg_replication_slots", table=True))
# Failover: promotion ends recovery and the standby becomes a primary.
print("In recovery before promotion:", psql(REPLICA, "SELECT pg_is_in_recovery()"))
subprocess.run(["docker", "exec", "-u", "postgres", REPLICA,
"pg_ctl", "promote", "-D", PGDATA], capture_output=True, check=True)
time.sleep(3)
print("In recovery after promotion: ", psql(REPLICA, "SELECT pg_is_in_recovery()"))
print("Write to promoted replica:",
psql(REPLICA, "INSERT INTO dr_test (note) "
"VALUES ('written after promotion') RETURNING note"))
Expected Output:
Replica reads: written on primary Replica writes: ERROR: cannot execute INSERT in a read-only transaction Primary view of the replica: client_addr | state | sync_state | replay_lag -------------+-----------+------------+----------------- 172.22.0.3 | streaming | async | 00:00:00.001073 (1 row) slot_name | active | wal_status ---------------+--------+------------ replica1_slot | t | reserved (1 row) In recovery before promotion: t In recovery after promotion: f Write to promoted replica: written after promotion INSERT 0 1
The replica has the row, refuses the write, and reports about a millisecond of replay lag on a local network. sync_state = async is the default and the one to understand: the primary commits without waiting for the replica, so a primary lost abruptly can take the last few transactions with it. Synchronous replication removes that window but makes every commit wait for a round trip to the other region, which is why cross-region DR is usually left asynchronous and the small data-loss window is accepted. After pg_ctl promote, pg_is_in_recovery() flips to false and writes succeed: the standby is now a primary in its own right. Note that promotion is one-way. The old primary cannot simply be restarted as a standby of the new one; it has to be rewound with pg_rewind or rebuilt from a fresh base backup.
Disaster Recovery Runbook (Keep This Printed!)
- Detect: Monitoring alerts on primary failure
- Assess: Determine severity and recovery option (PITR vs failover)
- Notify: Alert stakeholders, start incident channel
- Execute: Follow failover checklist:
- Stop application writes to failed primary
- Promote replica:
pg_ctl promote -D /data - Update DNS to point to new primary
- Verify application connectivity
- Verify: Test critical workflows, check data integrity
- Rebuild: Set up new replica in failed region
- Post-Mortem: Document incident, update procedures
Key Takeaways
- Test your backups religiously: Untested backups are worthless. Run automated restore tests weekly.
- 3-2-1 rule: 3 copies of data, 2 different media types, 1 offsite backup.
- RPO/RTO drive architecture: Define acceptable data loss and downtime before choosing backup strategy.
- PITR requires WAL archiving: Continuous WAL archiving enables recovery to any point in time.
- DR is more than backups: You need documented procedures, tested failover, multi-region infrastructure, and trained teams.