cd ..
docker

Restoring Docker PostgreSQL Safely: Zero Data-Loss Procedures

Master PostgreSQL database restoration in Docker with safety-first procedures. Learn pre-restore checks, pg_restore techniques, automatic rollback, and emergency recovery to ensure zero data loss.

14 min read

Backups only matter if you can restore them. I've seen countless situations where backups existed but couldn't be recovered—permissions were wrong, the restore procedure was never tested, or something went wrong mid-restore and nobody knew how to rollback. That experience taught me one critical lesson: restoration procedures are more important than backup creation.

This guide covers the complete restore strategy I use for production PostgreSQL databases in Docker. It prioritizes safety above all else—every restore can be rolled back, every step is verified, and data loss is nearly impossible if you follow these procedures.


Why Restore Safety Matters

Restores are inherently destructive. A single mistake can:

  • Overwrite current data with stale backups
  • Drop databases and lose recent changes
  • Lock out users mid-recovery
  • Leave your database in an inconsistent state

This guide solves these problems:

  • ✓ Pre-restore verification and safety checks
  • ✓ Zero-data-loss restore model (rename, don't delete)
  • ✓ Automatic rollback on failure
  • ✓ Post-restore verification and validation
  • ✓ Emergency recovery procedures

Critical Warnings: Read This First

⚠️ Restore operations are destructive. Before doing anything:

  1. Create a pre-restore backup - Always backup current state first
  2. Stop applications - Prevent writes during restore
  3. Verify backup integrity - Test with --dry-run first
  4. Plan your rollback - Know how to undo if things go wrong
  5. Check disk space - Restore needs space for old + new database

If you skip these steps and something goes wrong, recovery becomes much harder.


Prerequisites

Before attempting any restore:

  • ✓ Docker is installed and running
  • ✓ PostgreSQL container is running and accessible
  • ✓ Backup file exists and checksum is verified
  • ✓ You know the database container name
  • ✓ You have disk space for old database + new restore
  • ✓ You have read the "Pre-Restore Safety Checklist" section

Step 1: Pre-Restore Safety Checklist

Complete these checks before restoring to production. This is non-negotiable.

# Complete pre-restore verification checklist
pre_restore_checklist() {
    local backup_file="$1"
    local container="$2"
    local db_name="$3"
    
    echo "=== PRE-RESTORE SAFETY CHECKLIST ==="
    echo ""
    
    # Check 1: Backup file exists
    if [[ ! -f "$backup_file" ]]; then
        echo "✗ CRITICAL: Backup file not found: $backup_file"
        return 1
    fi
    echo "✓ Backup file exists"
    
    # Check 2: Backup checksum is valid
    local checksum_file="${backup_file}.sha256"
    if [[ -f "$checksum_file" ]]; then
        if sha256sum --check "$checksum_file" > /dev/null 2>&1; then
            echo "✓ Backup checksum verified"
        else
            echo "✗ CRITICAL: Checksum verification FAILED"
            return 1
        fi
    else
        echo "⚠ WARNING: No checksum file found"
    fi
    
    # Check 3: Container is running
    if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
        echo "✓ PostgreSQL container is running"
    else
        echo "✗ CRITICAL: Container not running: $container"
        return 1
    fi
    
    # Check 4: Database connectivity
    if docker exec "$container" pg_isready -U postgres > /dev/null 2>&1; then
        echo "✓ PostgreSQL is responding"
    else
        echo "✗ CRITICAL: PostgreSQL not responding"
        return 1
    fi
    
    # Check 5: Disk space
    local backup_size=$(du -h "$backup_file" | cut -f1)
    local available_space=$(df "$backup_file" | tail -1 | awk '{print $4}')
    # Rough estimate: need 2x backup size (old DB + new DB)
    if [[ $available_space -gt 2097152 ]]; then  # 2GB minimum
        echo "✓ Sufficient disk space available (${backup_size})"
    else
        echo "⚠ WARNING: Limited disk space available"
    fi
    
    # Check 6: Document current database state
    local state_file="/tmp/${db_name}_pre_restore_$(date +%s).txt"
    {
        echo "=== Pre-Restore Database State ==="
        echo "Timestamp: $(date -Iseconds)"
        echo ""
        echo "=== Table Count ==="
        docker exec "$container" psql -U postgres -d "$db_name" -t -c \
            "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null || echo "ERROR: Database may not exist"
        echo ""
        echo "=== Database Size ==="
        docker exec "$container" psql -U postgres -d "$db_name" -t -c \
            "SELECT pg_size_pretty(pg_database_size('$db_name'));" 2>/dev/null || echo "0 bytes"
    } > "$state_file"
    echo "✓ Current state documented in: $state_file"
    
    echo ""
    echo "=== READY TO RESTORE ==="
    echo "All pre-restore checks passed. You can proceed safely."
    return 0
}
 
# Usage
pre_restore_checklist /var/backups/postgresql/2026-03-31/my-postgres_mydb_2026-03-31T143022.sql.gz my-postgres mydb

What each check does:

  • Backup file exists - Obvious but critical
  • Checksum verified - Confirms backup wasn't corrupted during storage/transfer
  • Container running - Can't restore to a stopped container
  • PostgreSQL responding - Database is healthy enough to accept connections
  • Disk space - Restore needs space for both old and new databases
  • Document state - Audit trail of what was there before restore

Step 2: Create Pre-Restore Backup (Safety Net)

Before any restore, create a backup of the current database state. If the restore goes wrong, you can recover to this point.

# Create a safety backup of the current database
create_pre_restore_backup() {
    local container="$1"
    local db_name="$2"
    local db_user="${3:-postgres}"
    local backup_dir="${4:-/tmp/pre-restore-backups}"
    
    mkdir -p "$backup_dir"
    
    local timestamp=$(date '+%Y-%m-%dT%H%M%S')
    local backup_file="${backup_dir}/${container}_${db_name}_pre-restore_${timestamp}.sql.gz"
    
    echo "[INFO] Creating pre-restore safety backup..."
    
    # Create dump inside container
    local container_dump="/tmp/${db_name}_pre_restore.dump"
    
    if docker exec "$container" pg_dump -U "$db_user" -Fc -f "$container_dump" "$db_name" 2>/dev/null; then
        # Copy to host
        docker cp "$container:$container_dump" - | gzip > "$backup_file"
        docker exec "$container" rm -f "$container_dump"
        
        # Verify
        if [[ -s "$backup_file" ]]; then
            sha256sum "$backup_file" > "${backup_file}.sha256"
            local size=$(du -h "$backup_file" | cut -f1)
            echo "[OK] Pre-restore backup created: $backup_file ($size)"
            echo "[IMPORTANT] If restore fails, you can recover from: $backup_file"
            return 0
        fi
    fi
    
    echo "[ERROR] Failed to create pre-restore backup"
    return 1
}
 
# Usage - create safety net before restoring
create_pre_restore_backup my-postgres mydb postgres /tmp/pre-restore-backups

This backup gives you a safety net. If the restore goes wrong, you can recover the database to its state before the restore attempt.


Step 3: Stop Applications (Prevent Data Corruption)

Stop all applications writing to the database during restore. Concurrent writes will corrupt the database or cause the restore to fail.

# Stop all applications that write to the database
stop_applications() {
    echo "[INFO] Stopping application services..."
    
    # Stop application containers (adjust service names to yours)
    docker-compose stop app-api worker scheduler || true
    
    # Or for individual containers:
    # docker stop myapp
    
    # Verify they're stopped
    sleep 2
    
    echo "[OK] Applications stopped"
}
 
# Resume applications after restore
start_applications() {
    echo "[INFO] Starting application services..."
    
    docker-compose start app-api worker scheduler || true
    # Or: docker start myapp
    
    # Wait for apps to connect to database
    sleep 5
    
    echo "[OK] Applications started"
}
 
# Usage
stop_applications
# ... perform restore ...
start_applications

Why this matters: If applications are still writing during restore, the database can become corrupted or the restore can fail mid-operation.


Step 4: Extract Database Name from Backup Filename

Your backup filename format includes the database name. Extract it before restore.

# Extract database name from backup filename
# Format: {container_name}_{dbname}_{YYYY-MM-DDTHHMM01}.sql.gz
extract_db_name_from_backup() {
    local filename="$1"  # e.g., my-postgres_mydb_2026-03-31T143022.sql.gz
    
    # Remove extension and timestamp
    local without_ext="${filename%.*}"           # Remove .gz
    without_ext="${without_ext%.*}"              # Remove .sql
    
    # Remove timestamp suffix (last part after underscore matching date pattern)
    local without_timestamp="${without_ext%_[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*}"
    
    # Extract database name (everything after container name)
    # Container name is first part, everything else is database name
    local db_name="${without_timestamp#*_}"
    
    echo "$db_name"
}
 
# Extract container name from filename
extract_container_name_from_backup() {
    local filename="$1"
    
    # Remove extension
    local without_ext="${filename%.*}"
    without_ext="${without_ext%.*}"
    
    # Remove timestamp
    local without_timestamp="${without_ext%_[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T*}"
    
    # Container name is everything except the last underscore and db name
    local container_name="${without_timestamp%_*}"
    
    echo "$container_name"
}
 
# Usage examples:
extract_db_name_from_backup "my-postgres_mydb_2026-03-31T143022.sql.gz"
# Output: mydb
 
extract_db_name_from_backup "postgres_appdb_2026-03-31T143022.sql.gz"
# Output: appdb
 
extract_container_name_from_backup "my-postgres_mydb_2026-03-31T143022.sql.gz"
# Output: my-postgres

Why extraction is important: You need the database name to create the fresh database and restore into it.


Step 5: Database Restore with pg_restore

Perform the actual restore operation using pg_restore. This uses a safety-first approach:

  1. Rename existing database (don't delete)
  2. Create fresh database
  3. Restore from backup
  4. Verify success
# Zero-data-loss restore procedure
restore_database() {
    local container="$1"              # e.g., my-postgres
    local backup_file="$2"            # Path to backup file
    local target_db="$3"              # Database name to restore to
    local db_user="${4:-postgres}"    # Database user
    
    echo "[INFO] Starting restore: $backup_file -> $target_db"
    
    # Step 1: Verify backup file
    if [[ ! -f "$backup_file" ]]; then
        echo "[ERROR] Backup file not found: $backup_file"
        return 1
    fi
    
    # Step 2: Check if database exists
    local db_exists=$(docker exec "$container" psql -U "$db_user" -d postgres -t -c \
        "SELECT 1 FROM pg_database WHERE datname = '$target_db';" 2>/dev/null | grep -c 1 || echo 0)
    
    if [[ $db_exists -eq 1 ]]; then
        echo "[INFO] Database exists, will be replaced"
        
        # Step 3: Terminate existing connections to database
        echo "[INFO] Terminating existing connections..."
        docker exec "$container" psql -U "$db_user" -d postgres -c \
            "SELECT pg_terminate_backend(pid) 
             FROM pg_stat_activity 
             WHERE datname = '$target_db' AND pid <> pg_backend_pid();" 2>/dev/null || true
        
        # Step 4: Rename existing database (zero data-loss!)
        local old_db_name="old_${target_db}_$(date +%s)"
        echo "[INFO] Renaming existing database to: $old_db_name"
        
        if ! docker exec "$container" psql -U "$db_user" -d postgres -c \
            "ALTER DATABASE \"$target_db\" RENAME TO \"$old_db_name\";"; then
            echo "[ERROR] Failed to rename database"
            return 1
        fi
        
        echo "[OK] Old database preserved as: $old_db_name"
    else
        echo "[INFO] Database does not exist, will be created"
    fi
    
    # Step 5: Create fresh database
    echo "[INFO] Creating fresh database: $target_db"
    if ! docker exec "$container" psql -U "$db_user" -d postgres -c \
        "CREATE DATABASE $target_db;"; then
        echo "[ERROR] Failed to create database"
        return 1
    fi
    
    # Step 6: Restore from backup
    echo "[INFO] Restoring from backup (this may take a while)..."
    
    if [[ "$backup_file" == *.gz ]]; then
        # Decompress and pipe to pg_restore
        gunzip -c "$backup_file" | docker exec -i "$container" \
            pg_restore -U "$db_user" -d "$target_db" \
            --no-privileges \
            --no-owner \
            -1 2>&1
    else
        # Direct restore (uncompressed)
        cat "$backup_file" | docker exec -i "$container" \
            pg_restore -U "$db_user" -d "$target_db" \
            --no-privileges \
            --no-owner \
            -1 2>&1
    fi
    
    local restore_exit=$?
    
    if [[ $restore_exit -ne 0 ]]; then
        echo "[ERROR] Restore failed with exit code: $restore_exit"
        return 1
    fi
    
    echo "[OK] Database restore completed successfully"
    return 0
}
 
# Usage - replace values with your actual container and backup
restore_database my-postgres \
    /var/backups/postgresql/2026-03-31/my-postgres_mydb_2026-03-31T143022.sql.gz \
    mydb \
    postgres

pg_restore options explained:

  • --no-privileges - Don't restore user privileges (Docker user may differ from backup creator)
  • --no-owner - Don't restore object ownership (let container handle permissions)
  • -1 - Single transaction (all-or-nothing, either complete restore or rollback)

Zero-data-loss model:

  • If restore fails, old database still exists as old_${db}_${timestamp}
  • You can rename it back and recover
  • No data loss unless the restore completes successfully

Step 6: Verify Restore Success

After restore completes, verify the data integrity before allowing applications to resume.

# Comprehensive restore verification
verify_restore() {
    local container="$1"
    local db_name="$2"
    local db_user="${3:-postgres}"
    
    echo "=== RESTORE VERIFICATION ==="
    echo ""
    
    # Check 1: Database exists
    if docker exec "$container" psql -U "$db_user" -d postgres -t -c \
        "SELECT 1 FROM pg_database WHERE datname = '$db_name';" 2>/dev/null | grep -q 1; then
        echo "✓ Database exists: $db_name"
    else
        echo "✗ Database not found: $db_name"
        return 1
    fi
    
    # Check 2: Table count
    local table_count=$(docker exec "$container" psql -U "$db_user" -d "$db_name" -t -c \
        "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null | tr -d ' ')
    
    if [[ $table_count -gt 0 ]]; then
        echo "✓ Tables found: $table_count"
    else
        echo "✗ No tables found - restore may have failed"
        return 1
    fi
    
    # Check 3: Database size
    local db_size=$(docker exec "$container" psql -U "$db_user" -d "$db_name" -t -c \
        "SELECT pg_database_size('$db_name');" 2>/dev/null | tr -d ' ')
    
    if [[ $db_size -gt 0 ]]; then
        local readable_size=$(docker exec "$container" psql -U "$db_user" -d "$db_name" -t -c \
            "SELECT pg_size_pretty(pg_database_size('$db_name'));" 2>/dev/null)
        echo "✓ Database size: $readable_size"
    else
        echo "✗ Database size is 0 - restore failed"
        return 1
    fi
    
    # Check 4: Row counts (top 5 tables)
    echo ""
    echo "✓ Top 5 tables by row count:"
    docker exec "$container" psql -U "$db_user" -d "$db_name" -c \
        "SELECT relname, n_live_tup 
         FROM pg_stat_user_tables 
         WHERE schemaname = 'public' 
         ORDER BY n_live_tup DESC LIMIT 5;" 2>/dev/null
    
    echo ""
    echo "=== VERIFICATION PASSED ==="
    echo "Restored database is ready for use"
    return 0
}
 
# Usage
verify_restore my-postgres mydb postgres

Verification checks:

  • Database exists in PostgreSQL
  • At least one table exists
  • Database has data (size > 0)
  • Row counts match expected schema

Step 7: Automatic Rollback on Failure

If restore fails, rollback to the pre-restore state automatically.

# Automatic rollback procedure
automatic_rollback() {
    local container="$1"
    local target_db="$2"
    local db_user="${3:-postgres}"
    
    echo "[WARN] Rollback triggered - restore failed"
    
    # Find the old renamed database
    local old_db=$(docker exec "$container" psql -U "$db_user" -d postgres -t -c \
        "SELECT datname FROM pg_database 
         WHERE datname LIKE 'old_${target_db}_%' 
         ORDER BY datname DESC LIMIT 1;" 2>/dev/null | tr -d ' ')
    
    if [[ -z "$old_db" ]]; then
        echo "[ERROR] No old database found for rollback"
        echo "[CRITICAL] Manual recovery required"
        return 1
    fi
    
    echo "[INFO] Found old database: $old_db"
    
    # Terminate connections to failed new database
    docker exec "$container" psql -U "$db_user" -d postgres -c \
        "SELECT pg_terminate_backend(pid) FROM pg_stat_activity 
         WHERE datname = '$target_db';" 2>/dev/null || true
    
    # Drop the failed database
    echo "[INFO] Dropping failed database: $target_db"
    docker exec "$container" psql -U "$db_user" -d postgres -c \
        "DROP DATABASE IF EXISTS $target_db;" 2>/dev/null || true
    
    # Rename old database back to original name
    echo "[INFO] Restoring original database..."
    if docker exec "$container" psql -U "$db_user" -d postgres -c \
        "ALTER DATABASE \"$old_db\" RENAME TO \"$target_db\";"; then
        echo "[OK] Rollback complete - database restored to pre-restore state"
        return 0
    else
        echo "[ERROR] Rollback failed"
        return 1
    fi
}
 
# Usage in restore script
if ! restore_database my-postgres backup.sql.gz mydb postgres; then
    automatic_rollback my-postgres mydb postgres
    echo "[CRITICAL] Restore failed and was rolled back"
    exit 1
fi
 
if ! verify_restore my-postgres mydb postgres; then
    automatic_rollback my-postgres mydb postgres
    echo "[CRITICAL] Verification failed and was rolled back"
    exit 1
fi
 
echo "[OK] Restore completed successfully"

Rollback safety:

  • Finds the old renamed database
  • Drops the failed new database
  • Renames old database back to original name
  • Zero data loss if procedure is followed

Step 8: Complete Restore Workflow

Combine all steps into a complete, safe restore workflow:

#!/usr/bin/env bash
# Complete restore workflow with safety checks
 
set -euo pipefail
 
# Configuration
CONTAINER="$1"          # PostgreSQL container name
BACKUP_FILE="$2"        # Path to backup file
TARGET_DB="${3:-}"      # Target database (optional, extracted from filename if not provided)
DB_USER="${4:-postgres}"
 
# Logging
log_info() { echo "[INFO] $1"; }
log_error() { echo "[ERROR] $1" >&2; }
log_ok() { echo "[OK] $1"; }
 
# Step 1: Extract database name if not provided
if [[ -z "$TARGET_DB" ]]; then
    TARGET_DB=$(echo "$(basename "$BACKUP_FILE")" | sed 's/.*_\([^_]*\)_[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T.*/\1/')
    log_info "Extracted database name: $TARGET_DB"
fi
 
# Step 2: Pre-restore checklist
log_info "Running pre-restore checks..."
if [[ ! -f "$BACKUP_FILE" ]]; then
    log_error "Backup file not found: $BACKUP_FILE"
    exit 1
fi
 
checksum_file="${BACKUP_FILE}.sha256"
if [[ -f "$checksum_file" ]]; then
    if ! sha256sum --check "$checksum_file" > /dev/null 2>&1; then
        log_error "Checksum verification FAILED"
        exit 1
    fi
    log_ok "Checksum verified"
fi
 
# Step 3: Create pre-restore backup
log_info "Creating pre-restore safety backup..."
pre_backup="/tmp/${CONTAINER}_${TARGET_DB}_$(date +%s).dump.gz"
docker exec "$CONTAINER" pg_dump -U "$DB_USER" -Fc -f "/tmp/pre_restore.dump" "$TARGET_DB" 2>/dev/null || {
    log_error "Failed to create pre-restore backup"
    exit 1
}
docker cp "$CONTAINER:/tmp/pre_restore.dump" - | gzip > "$pre_backup"
docker exec "$CONTAINER" rm -f "/tmp/pre_restore.dump"
log_ok "Pre-restore backup: $pre_backup"
 
# Step 4: Perform restore
log_info "Performing restore..."
if [[ "$BACKUP_FILE" == *.gz ]]; then
    gunzip -c "$BACKUP_FILE" | docker exec -i "$CONTAINER" \
        pg_restore -U "$DB_USER" -d "$TARGET_DB" --no-privileges --no-owner -1 2>&1 || {
        log_error "Restore failed"
        log_info "Rolling back..."
        # Find and rename old database back
        OLD_DB=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -t -c \
            "SELECT datname FROM pg_database WHERE datname LIKE 'old_${TARGET_DB}_%' ORDER BY datname DESC LIMIT 1;" 2>/dev/null | tr -d ' ')
        [[ -n "$OLD_DB" ]] && docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -c \
            "DROP DATABASE IF EXISTS $TARGET_DB; ALTER DATABASE \"$OLD_DB\" RENAME TO \"$TARGET_DB\";"
        exit 1
    }
fi
 
# Step 5: Verify restore
log_info "Verifying restored database..."
table_count=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d "$TARGET_DB" -t -c \
    "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null | tr -d ' ')
 
if [[ $table_count -eq 0 ]]; then
    log_error "No tables found after restore"
    exit 1
fi
 
log_ok "Restore verification passed ($table_count tables)"
 
# Step 6: Clean up old database
log_info "Cleaning up old database..."
OLD_DBS=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -t -c \
    "SELECT datname FROM pg_database WHERE datname LIKE 'old_${TARGET_DB}_%';" 2>/dev/null | tr -d ' ')
 
if [[ -n "$OLD_DBS" ]]; then
    while IFS= read -r old_db; do
        [[ -n "$old_db" ]] && docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -c \
            "DROP DATABASE \"$old_db\";" 2>/dev/null || true
    done <<< "$OLD_DBS"
    log_ok "Old databases cleaned up"
fi
 
log_ok "=== RESTORE COMPLETED SUCCESSFULLY ==="
log_info "Database: $TARGET_DB"
log_info "Safety backup: $pre_backup"

Make it executable and use:

chmod +x restore.sh
./restore.sh my-postgres /var/backups/postgresql/2026-03-31/my-postgres_mydb_2026-03-31T143022.sql.gz

Troubleshooting Restore Failures

IssueCauseSolution
"Backup file is empty"File corrupted or truncatedCheck checksum, verify backup file integrity
"Connection refused"PostgreSQL not respondingEnsure container is running: docker ps
"Permission denied"User lacks privilegesUse postgres user or admin account
"Restore hangs"Large database taking timeLeave it running, check with docker exec -it in another terminal
"Checksum mismatch"Backup corrupted in transitRe-download or verify source backup
"Insufficient space"Disk full during restoreFree space and try again, or use smaller backup

Emergency Manual Recovery

If automatic procedures fail, manual recovery is always possible:

# Check for old databases
docker exec my-postgres psql -U postgres -d postgres -t -c \
    "SELECT datname FROM pg_database WHERE datname LIKE 'old_%' ORDER BY datname DESC;"
 
# Manually rename old database back
docker exec my-postgres psql -U postgres -d postgres -c \
    "ALTER DATABASE \"old_mydb_1711353022\" RENAME TO \"mydb\";"
 
# Drop failed new database
docker exec my-postgres psql -U postgres -d postgres -c \
    "DROP DATABASE IF EXISTS mydb;"

Key Takeaways

  1. Always backup before restore - Pre-restore backup is your safety net
  2. Rename, don't delete - Preserves old database if restore fails
  3. Verify checksums - Corrupted backups waste time and cause failures
  4. Test before production - Run --dry-run on a staging database first
  5. Verify after restore - Check table counts and data size before resuming apps
  6. Document everything - Keep audit trail of when restores happened

Next Steps

Your restoration procedures are now production-hardened. In Part 3, we'll cover automating backups with cron so restoration is rarely necessary.

More to Read