Skip to content

Backup & Recovery Drill

A backup is only valuable if it can be successfully restored. Many teams discover their backups are corrupted, incomplete, or that the recovery process is undocumented only when they actually need to recover data. Regular backup recovery drills are a critical practice for ensuring business continuity.

RiskConsequenceHow Drills Help
Corrupted backup filesUnable to recover any dataVerifies backup integrity
Undocumented recovery processPanic and excessive downtime during incidentsProduces a standard operating procedure
Incomplete backup dataCritical data missingReveals overlooked backup targets
Staff turnoverOnly specific people know how to restoreAll team members can execute recovery
Environment differencesBackup cannot be restored in test environmentExposes compatibility issues

Prepare a test server with a configuration similar to production:

Terminal window
# Confirm test server basics
cat /etc/redhat-release
uname -r
df -h
free -h

Ensure the test environment has the necessary recovery tools:

Terminal window
dnf install -y \
borgbackup \
mysql \
postgresql \
tar \
rsync \
gzip \
xz

Document the backup sources to be verified:

Terminal window
# List available backup archives
# Borg backups
borg list /backup/borg-repo
# Or list remote backups
borg list ssh://backup@backup-server/repo
# Record the latest backup timestamp
borg info /backup/borg-repo::latest

Before restoring, confirm that the backup files themselves are intact.

Terminal window
# Verify repository integrity
borg check /backup/borg-repo
# Verify a specific archive
borg check --archives-only /backup/borg-repo::2026-03-24_0200
# Deep verification (includes data chunk validation, takes longer)
borg check --verify-data /backup/borg-repo
Terminal window
# Test tar archive integrity
tar -tzf /backup/full-backup-20260324.tar.gz > /dev/null
echo "Exit code: $?"
# Exit code 0 means success
# If GPG encrypted
gpg --decrypt /backup/full-backup-20260324.tar.gz.gpg | tar -tz > /dev/null
Terminal window
# Compare source and backup for differences
rsync -avnc /source/path/ /backup/rsync-mirror/ > /tmp/rsync-diff.txt
cat /tmp/rsync-diff.txt
# Empty diff means consistent
Terminal window
# Create a temporary restore directory
mkdir -p /tmp/restore-test
# Extract specific directories from Borg
cd /tmp/restore-test
borg extract /backup/borg-repo::latest etc/
# Compare critical configuration files
diff /tmp/restore-test/etc/ssh/sshd_config /etc/ssh/sshd_config
diff /tmp/restore-test/etc/firewalld/ /etc/firewalld/ -r
Terminal window
# Extract application directory from Borg
cd /tmp/restore-test
borg extract /backup/borg-repo::latest var/www/myapp/
# Verify file count and size
find /tmp/restore-test/var/www/myapp -type f | wc -l
du -sh /tmp/restore-test/var/www/myapp

2.3 Verify File Permissions and Attributes

Section titled “2.3 Verify File Permissions and Attributes”

After restoration, verify that file permissions are correct:

Terminal window
# Check permissions on critical directories
ls -la /tmp/restore-test/etc/ssh/
ls -la /tmp/restore-test/var/www/myapp/
# Check SELinux contexts
ls -Z /tmp/restore-test/var/www/myapp/
Terminal window
rm -rf /tmp/restore-test
Terminal window
# Install MySQL/MariaDB on the test server
dnf install -y mysql-server
systemctl start mysqld
# Create a dedicated test database
mysql -u root -e "CREATE DATABASE restore_test;"
Terminal window
# Inspect the backup file
ls -lh /backup/mysql/mydb-20260324.sql.gz
zcat /backup/mysql/mydb-20260324.sql.gz | head -30
# Restore into the test database
zcat /backup/mysql/mydb-20260324.sql.gz | mysql -u root restore_test
Terminal window
# Check table count
mysql -u root -e "SELECT COUNT(*) AS table_count FROM information_schema.tables WHERE table_schema='restore_test';"
# Check row counts per table
mysql -u root -e "
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema='restore_test'
ORDER BY table_name;
"
# Spot-check critical data
mysql -u root -e "SELECT COUNT(*) FROM restore_test.users;"
mysql -u root -e "SELECT COUNT(*) FROM restore_test.orders;"
# Check latest record timestamp (confirms backup point in time)
mysql -u root -e "SELECT MAX(created_at) AS latest_record FROM restore_test.orders;"

3.4 Restore XtraBackup Physical Backup (If Used)

Section titled “3.4 Restore XtraBackup Physical Backup (If Used)”
Terminal window
# Prepare the backup
xtrabackup --prepare --target-dir=/backup/xtrabackup/20260324
# Restore to a test directory
systemctl stop mysqld
xtrabackup --copy-back --target-dir=/backup/xtrabackup/20260324 --datadir=/var/lib/mysql-test
# Fix ownership
chown -R mysql:mysql /var/lib/mysql-test
# Start with the test data directory
mysqld --datadir=/var/lib/mysql-test --port=3307 &
# Verify
mysql -P 3307 -u root -e "SHOW DATABASES;"
Terminal window
mysql -u root -e "DROP DATABASE restore_test;"
Terminal window
# Install PostgreSQL on the test server
dnf install -y postgresql-server
postgresql-setup --initdb
systemctl start postgresql
Terminal window
# Inspect the backup
ls -lh /backup/postgresql/mydb-20260324.sql.gz
zcat /backup/postgresql/mydb-20260324.sql.gz | head -30
# Create the test database and user
sudo -u postgres createdb restore_test
sudo -u postgres createuser --no-password testuser
# Restore
zcat /backup/postgresql/mydb-20260324.sql.gz | sudo -u postgres psql restore_test
Terminal window
# If using pg_dump -Fc format
sudo -u postgres pg_restore -d restore_test /backup/postgresql/mydb-20260324.dump
# Restore only a specific table
sudo -u postgres pg_restore -d restore_test -t users /backup/postgresql/mydb-20260324.dump
Terminal window
# Check table count
sudo -u postgres psql restore_test -c "
SELECT COUNT(*) AS table_count
FROM information_schema.tables
WHERE table_schema = 'public';
"
# Check row counts per table
sudo -u postgres psql restore_test -c "
SELECT schemaname, relname AS table_name, n_live_tup AS row_count
FROM pg_stat_user_tables
ORDER BY relname;
"
# Spot-check data
sudo -u postgres psql restore_test -c "SELECT COUNT(*) FROM users;"
sudo -u postgres psql restore_test -c "SELECT MAX(created_at) FROM orders;"
# Verify index integrity
sudo -u postgres psql restore_test -c "REINDEX DATABASE restore_test;"

4.5 Point-in-Time Recovery (PITR) with WAL

Section titled “4.5 Point-in-Time Recovery (PITR) with WAL”
Terminal window
# Prepare the restore directory
mkdir -p /var/lib/pgsql/restore-test
# Restore the base backup
tar xzf /backup/postgresql/base-20260324.tar.gz -C /var/lib/pgsql/restore-test/
# Configure recovery parameters
cat > /var/lib/pgsql/restore-test/postgresql.auto.conf << 'EOF'
restore_command = 'cp /backup/postgresql/wal/%f %p'
recovery_target_time = '2026-03-24 12:00:00'
recovery_target_action = 'promote'
EOF
# Create the recovery signal file
touch /var/lib/pgsql/restore-test/recovery.signal
# Fix ownership
chown -R postgres:postgres /var/lib/pgsql/restore-test/
# Start the recovery instance on a different port
sudo -u postgres pg_ctl start -D /var/lib/pgsql/restore-test -o "-p 5433"
# Verify the recovery point
psql -p 5433 -U postgres -c "SELECT pg_last_xact_replay_timestamp();"
Terminal window
sudo -u postgres dropdb restore_test
# If a PITR test instance was started
sudo -u postgres pg_ctl stop -D /var/lib/pgsql/restore-test
rm -rf /var/lib/pgsql/restore-test

Every drill must produce a detailed report. Use the following template:

===== Backup Recovery Drill Report =====
Date: 2026-03-25
Performed by: [Name]
Environment: Test server restore-test-01
--- Backup Integrity Verification ---
[PASS/FAIL] Borg repository check
[PASS/FAIL] Data chunk integrity
Notes:
--- File Recovery ---
[PASS/FAIL] System configuration files restored
[PASS/FAIL] Application data restored
[PASS/FAIL] File permissions correct
Notes:
--- MySQL Recovery ---
[PASS/FAIL] Backup file loaded successfully
[PASS/FAIL] Table count matches: Production XX tables, Restored XX tables
[PASS/FAIL] Row counts match
[PASS/FAIL] Latest data timestamp as expected
Recovery time: XX minutes
Notes:
--- PostgreSQL Recovery ---
[PASS/FAIL] Backup file loaded successfully
[PASS/FAIL] Table count matches: Production XX tables, Restored XX tables
[PASS/FAIL] Row counts match
[PASS/FAIL] Index rebuild successful
Recovery time: XX minutes
Notes:
--- Issues Discovered ---
1. [Describe issue and impact]
2. [Describe issue and impact]
--- Remediation Actions ---
1. [Specific action and owner]
2. [Specific action and owner]
--- Next Drill Planned ---
Date: 2026-06-25
Scope: [Full / Incremental / Specific system]

Phase 6: Establish a Quarterly Drill Schedule

Section titled “Phase 6: Establish a Quarterly Drill Schedule”
QuarterMonthDrill ScopeEstimated Duration
Q1MarchFull recovery drill (files + databases)4 hours
Q2JuneDatabase recovery focus + PITR test2 hours
Q3SeptemberFull recovery drill (including disaster recovery scenario)4 hours
Q4DecemberFile recovery + annual backup strategy review3 hours
Terminal window
# Create a quarterly drill reminder cron job
cat > /etc/cron.d/backup-drill-reminder << 'EOF'
# Send reminder on the 1st of each quarter month
0 9 1 3,6,9,12 * root echo "Backup recovery drill reminder: Please complete the quarterly drill this month." | mail -s "[ACTION] Quarterly Backup Recovery Drill" [email protected]
EOF

In addition to manual drills, automate daily backup validation:

cat > /usr/local/bin/backup-verify.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
LOG="/var/log/backup-verify.log"
ALERT_EMAIL="[email protected]"
ERRORS=0
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"
}
log "===== Starting backup verification ====="
# 1. Check Borg backup
if borg check /backup/borg-repo 2>> "$LOG"; then
log "[PASS] Borg repository integrity check passed"
else
log "[FAIL] Borg repository integrity check failed"
((ERRORS++))
fi
# 2. Check latest backup age (should be less than 25 hours)
LATEST=$(borg list --sort-by timestamp --last 1 --format '{time}' /backup/borg-repo)
LATEST_TS=$(date -d "$LATEST" +%s)
NOW_TS=$(date +%s)
AGE_HOURS=$(( (NOW_TS - LATEST_TS) / 3600 ))
if [ "$AGE_HOURS" -lt 25 ]; then
log "[PASS] Latest backup is ${AGE_HOURS} hours old"
else
log "[FAIL] Latest backup is stale: ${AGE_HOURS} hours old"
((ERRORS++))
fi
# 3. Check backup size (should not be zero or abnormally small)
LATEST_SIZE=$(borg info /backup/borg-repo::latest --json | python3 -c "import sys,json; print(json.load(sys.stdin)['archives'][0]['stats']['original_size'])")
if [ "$LATEST_SIZE" -gt 1048576 ]; then
log "[PASS] Backup size is normal: $LATEST_SIZE bytes"
else
log "[FAIL] Backup size is abnormal: $LATEST_SIZE bytes"
((ERRORS++))
fi
# Report results
if [ "$ERRORS" -gt 0 ]; then
log "Verification complete, found ${ERRORS} issue(s)"
tail -20 "$LOG" | mail -s "[ALERT] Backup verification failed (${ERRORS} errors)" "$ALERT_EMAIL"
else
log "Verification complete, all checks passed"
fi
SCRIPT
chmod +x /usr/local/bin/backup-verify.sh

Schedule daily automated verification:

Terminal window
cat > /etc/cron.d/backup-verify << 'EOF'
0 6 * * * root /usr/local/bin/backup-verify.sh
EOF
IssueCauseSolution
Database fails to start after restoreVersion mismatchEnsure test environment uses the same database version as production
Incorrect file permissions after restoretar extraction didn’t preserve attributesUse tar -xpf to preserve permissions; run restorecon -Rv after restore
Borg reports repository corruptionInterrupted backup or disk failureRun borg check --repair; check backup disk health
Recovery takes too longLarge data volumeConsider incremental restore strategies; use faster storage media
Missing WAL filesArchiving misconfigurationCheck archive_command settings; ensure archive directory has sufficient space

Backup recovery drills are not a one-time task but an ongoing operational practice. Key takeaways:

  1. Verify first - An unverified backup cannot be trusted
  2. Document everything - Every drill should produce an actionable runbook
  3. Drill regularly - At least once per quarter for a full drill
  4. Involve everyone - Do not depend on a single person
  5. Continuously improve - Review and optimize the process after each drill