Backup, Recovery & Disaster Planning

Protecting your data from hardware failures, human errors, 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 2019, a ransomware attack encrypted MyBook's entire cloud storage, customers lost years of files because backups were stored on the same system. In 2021, a Facebook outage deleted BGP routes; recovery took 7 hours because disaster recovery procedures weren't tested. 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. You'll learn production patterns from Netflix, Stripe, and AWS that ensure 99.99% durability.

Critical Truth:31% of companies have never tested their backups. 60% of companies that lose data shut down within 6 months. The only backup that matters is the one you can successfully restore.

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

ScenarioRPORTOCostSolution
E-commerce (peak season)0 min5 min$$$$$Multi-region active-active
SaaS app5 min1 hour$$$Streaming replication + backups
Internal tools1 hour4 hours$$Hourly backups + snapshots
Analytics DB24 hours24 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

def full_backup_postgres(db_name, backup_dir):
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    backup_file = f"{backup_dir}/{db_name}_full_{timestamp}.sql"

    # Execute pg_dump
    cmd = [
        'pg_dump',
        '-h', 'localhost',
        '-U', 'postgres',
        '-d', db_name,
        '-F', 'c',  # Custom format (compressed)
        '-f', backup_file
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode == 0:
        print(f"✅ Full backup completed: {backup_file}")
        # Get file size
        import os
        size_mb = os.path.getsize(backup_file) / (1024 * 1024)
        print(f"   Size: {size_mb:.2f} MB")
        return backup_file
    else:
        print(f"❌ Backup failed: {result.stderr}")
        return None

# Usage
backup_file = full_backup_postgres('mydb', '/backups')
Result:
✅ Full backup completed: /backups/mydb_full_20250205_143022.sql
   Size: 1247.85 MB

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)
# PostgreSQL WAL-based incremental backup
def incremental_backup_postgres(db_name, wal_archive_dir, backup_dir):
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')

    # Archive WAL files (Write-Ahead Logs)
    # These contain all changes since last backup
    cmd = [
        'pg_basebackup',
        '-h', 'localhost',
        '-U', 'postgres',
        '-D', f"{backup_dir}/incremental_{timestamp}",
        '-Ft',  # Tar format
        '-z',   # Compress
        '-P',   # Progress
        '--wal-method=fetch'
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode == 0:
        print(f"✅ Incremental backup completed")
        print(f"   WAL files archived to: {wal_archive_dir}")
        return True
    else:
        print(f"❌ Backup failed: {result.stderr}")
        return False

# Usage
incremental_backup_postgres('mydb', '/wal_archive', '/backups')
Result:
✅ Incremental backup completed
   WAL files archived to: /wal_archive
   Size: 87.3 MB (vs 1247.85 MB for full backup)

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
archive_command = 'cp %p /wal_archive/%f'  # Copy WAL to archive dir

# 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

# After changing config, reload PostgreSQL:
# sudo systemctl reload postgresql
Configuration: PostgreSQL now continuously archives WAL files to /wal_archive/. These enable PITR.

Step 2: Create Base Backup for PITR

# Python script to create PITR base backup
import subprocess
from datetime import datetime

def create_pitr_base_backup(backup_dir):
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    backup_path = f"{backup_dir}/base_{timestamp}"

    # Create base backup with WAL files
    cmd = [
        'pg_basebackup',
        '-h', 'localhost',
        '-U', 'postgres',
        '-D', backup_path,
        '-Ft',              # Tar format
        '-z',               # Compress
        '-P',               # Show progress
        '--wal-method=stream',  # Include WAL files
        '--checkpoint=fast'     # Force immediate checkpoint
    ]

    print(f"Creating PITR base backup...")
    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode == 0:
        print(f"✅ Base backup created: {backup_path}")
        print(f"   Includes WAL files for PITR")
        return backup_path
    else:
        print(f"❌ Backup failed: {result.stderr}")
        return None

# Usage
backup_path = create_pitr_base_backup('/backups')
Result:
Creating PITR base backup...
✅ Base backup created: /backups/base_20250205_140000
   Includes WAL files for PITR

Step 3: Perform Point-in-Time Recovery

# Python script to perform PITR
import subprocess
import shutil
from datetime import datetime

def perform_pitr(base_backup_path, wal_archive_dir, target_time, restore_dir):
    """
    Restore database to specific point in time

    Args:
        base_backup_path: Path to base backup
        wal_archive_dir: Directory with archived WAL files
        target_time: Timestamp to restore to (e.g., '2025-02-05 14:30:00')
        restore_dir: Where to restore database
    """

    print(f"Starting PITR to {target_time}...")

    # 1. Extract base backup
    print("Step 1: Extracting base backup...")
    subprocess.run(['tar', '-xzf', f"{base_backup_path}/base.tar.gz", '-C', restore_dir])

    # 2. Create recovery configuration
    recovery_conf = f"""
restore_command = 'cp {wal_archive_dir}/%f %p'
recovery_target_time = '{target_time}'
recovery_target_action = 'promote'
    """

    with open(f"{restore_dir}/recovery.signal", 'w') as f:
        f.write("")  # Signal file for recovery mode

    with open(f"{restore_dir}/postgresql.auto.conf", 'a') as f:
        f.write(recovery_conf)

    print(f"✅ PITR configuration created")
    print(f"   Target time: {target_time}")
    print(f"   Restore directory: {restore_dir}")
    print(f"\n   Start PostgreSQL with this data directory to complete recovery")

    return restore_dir

# Usage: Restore to 1 minute before disaster
target_time = '2025-02-05 15:46:00'
restore_dir = perform_pitr(
    base_backup_path='/backups/base_20250205_140000',
    wal_archive_dir='/wal_archive',
    target_time=target_time,
    restore_dir='/var/lib/postgresql/14/main_restored'
)
Result:
Starting PITR to 2025-02-05 15:46:00...
Step 1: Extracting base backup...
✅ PITR configuration created
   Target time: 2025-02-05 15:46:00
   Restore directory: /var/lib/postgresql/14/main_restored

   Start PostgreSQL with this data directory to complete recovery

When PostgreSQL starts, it will replay WAL files up to 15:46:00,
restoring database to exact state 1 minute before the disaster!

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 script
import subprocess
import psycopg2
from datetime import datetime

def test_backup_restore(backup_file, test_db_name="test_restore_db"):
    """
    Test backup by actually restoring it and verifying data
    """
    print(f"Testing backup: {backup_file}")
    start_time = datetime.now()

    # Step 1: Drop test database if exists
    print("Step 1: Cleaning up old test database...")
    conn = psycopg2.connect("dbname=postgres user=postgres")
    conn.autocommit = True
    cur = conn.cursor()

    cur.execute(f"DROP DATABASE IF EXISTS {test_db_name}")
    cur.execute(f"CREATE DATABASE {test_db_name}")
    conn.close()

    # Step 2: Restore backup to test database
    print("Step 2: Restoring backup...")
    cmd = [
        'pg_restore',
        '-h', 'localhost',
        '-U', 'postgres',
        '-d', test_db_name,
        '-v',  # Verbose
        backup_file
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        print(f"❌ Restore failed: {result.stderr}")
        return False

    # Step 3: Verify data integrity
    print("Step 3: Verifying data...")
    conn = psycopg2.connect(f"dbname={test_db_name} user=postgres")
    cur = conn.cursor()

    # Check table counts
    cur.execute("""
        SELECT schemaname, tablename, COUNT(*)
        FROM pg_tables
        WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
        GROUP BY schemaname, tablename
    """)

    tables = cur.fetchall()
    print(f"   Found {len(tables)} tables")

    # Verify critical tables have data
    cur.execute("SELECT COUNT(*) FROM users")
    user_count = cur.fetchone()[0]
    print(f"   Users table: {user_count} rows")

    cur.execute("SELECT COUNT(*) FROM transactions")
    tx_count = cur.fetchone()[0]
    print(f"   Transactions table: {tx_count} rows")

    conn.close()

    # Step 4: Cleanup
    print("Step 4: Cleaning up test database...")
    conn = psycopg2.connect("dbname=postgres user=postgres")
    conn.autocommit = True
    cur = conn.cursor()
    cur.execute(f"DROP DATABASE {test_db_name}")
    conn.close()

    elapsed = (datetime.now() - start_time).total_seconds()
    print(f"✅ Backup test PASSED in {elapsed:.2f}s")
    print(f"   Backup is valid and restorable!")

    return True

# Usage: Run weekly backup verification
test_backup_restore('/backups/mydb_full_20250205_143022.sql')
Result:
Testing backup: /backups/mydb_full_20250205_143022.sql
Step 1: Cleaning up old test database...
Step 2: Restoring backup...
Step 3: Verifying data...
   Found 47 tables
   Users table: 12453 rows
   Transactions table: 894231 rows
Step 4: Cleaning up test database...
✅ Backup test PASSED in 47.23s
   Backup is valid and restorable!

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

TierDescriptionRTORPOCost
Tier 0: No DRBackups on same site. Hope for the best.Days-Weeks24+ hours$
Tier 1: Cold SiteBackups in remote location. Manual restore.12-24 hours12-24 hours$$
Tier 2: Warm SiteStandby database with delayed replication.1-4 hours1-4 hours$$$
Tier 3: Hot SiteReal-time replica, ready for failover.5-30 min0-5 min$$$$
Tier 4: Active-ActiveMulti-region, serving traffic simultaneously.0-5 min0 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

# Setup streaming replication to DR region

# PRIMARY (US-East): postgresql.conf
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB
hot_standby = on

# Create replication user on PRIMARY
CREATE USER replication_user WITH REPLICATION PASSWORD 'secure_password';

# Allow replication connection in pg_hba.conf
# host replication replication_user DR_REPLICA_IP/32 md5

# REPLICA (US-West): Setup streaming from primary
pg_basebackup -h PRIMARY_IP -D /var/lib/postgresql/data -U replication_user -P -R

# REPLICA: postgresql.auto.conf (created by -R flag)
primary_conninfo = 'host=PRIMARY_IP port=5432 user=replication_user password=secure_password'
Result: DR replica in US-West continuously streams changes from US-East primary. If primary fails, promote replica to become new primary.

Disaster Recovery Runbook (Keep This Printed!)

  1. Detect: Monitoring alerts on primary failure
  2. Assess: Determine severity and recovery option (PITR vs failover)
  3. Notify: Alert stakeholders, start incident channel
  4. 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
  5. Verify: Test critical workflows, check data integrity
  6. Rebuild: Set up new replica in failed region
  7. 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.