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.
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
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')
✅ 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')
✅ 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
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')
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'
)
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')
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
| 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
# 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'
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.