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.
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:
- Create a pre-restore backup - Always backup current state first
- Stop applications - Prevent writes during restore
- Verify backup integrity - Test with
--dry-runfirst - Plan your rollback - Know how to undo if things go wrong
- 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 mydbWhat 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-backupsThis 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_applicationsWhy 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-postgresWhy 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:
- Rename existing database (don't delete)
- Create fresh database
- Restore from backup
- 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 \
postgrespg_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 postgresVerification 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.gzTroubleshooting Restore Failures
| Issue | Cause | Solution |
|---|---|---|
| "Backup file is empty" | File corrupted or truncated | Check checksum, verify backup file integrity |
| "Connection refused" | PostgreSQL not responding | Ensure container is running: docker ps |
| "Permission denied" | User lacks privileges | Use postgres user or admin account |
| "Restore hangs" | Large database taking time | Leave it running, check with docker exec -it in another terminal |
| "Checksum mismatch" | Backup corrupted in transit | Re-download or verify source backup |
| "Insufficient space" | Disk full during restore | Free 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
- Always backup before restore - Pre-restore backup is your safety net
- Rename, don't delete - Preserves old database if restore fails
- Verify checksums - Corrupted backups waste time and cause failures
- Test before production - Run
--dry-runon a staging database first - Verify after restore - Check table counts and data size before resuming apps
- Document everything - Keep audit trail of when restores happened
Next Steps
- Read Part 1: Docker PostgreSQL Backup Strategies
- Read Part 3: Automating Backups with Cron
- Save the restore script: Store it in your infrastructure repo
- Test a restore: Practice with a staging database before production
Your restoration procedures are now production-hardened. In Part 3, we'll cover automating backups with cron so restoration is rarely necessary.