Skip to main content

Backup Strategies

A reliable backup strategy isn't just "run rsync every night." It's a layered approach that balances recovery speed, storage costs, and data freshness — ensuring you can restore any server to any point in time.

Backup Types

Full Backup

Copies everything every time:

rsync -av /var/www/html/ /backup/full-$(date +%F)/

Pros: Self-contained, simplest restore Cons: Slow, uses the most storage

Copies only changed files since the last backup. Unchanged files are hard-linked:

rsync -av --link-dest=/backup/latest \
/var/www/html/ /backup/$(date +%F)/
ln -sfn /backup/$(date +%F) /backup/latest

Pros: Fast, space-efficient, each snapshot looks like a full backup Cons: Relies on hard links (same filesystem required for space savings)

Differential Backup (--compare-dest)

Copies only files that differ from a reference point (usually the last full backup):

rsync -av --compare-dest=/backup/full-2024-01-01/ \
/var/www/html/ /backup/diff-$(date +%F)/

Pros: Smaller than full, faster restore than incremental chain Cons: Grows larger over time until next full backup

Comparison

StrategySpeedStorageRestore SpeedComplexity
FullSlowestHighestFastestLowest
IncrementalFastestLowestFast (each snapshot is complete)Medium
DifferentialMediumMediumMedium (full + diff)Medium

The 3-2-1 Rule

The gold standard for backup architecture:

flowchart LR
SRC["Production Server"] --> L["Local Backup<br/>(Copy 1)"]
SRC --> R["Remote Server<br/>(Copy 2)"]
SRC --> C["Cloud/Offsite<br/>(Copy 3)"]

L -.- M1["Media 1: Local Disk"]
R -.- M2["Media 2: Remote Server"]
C -.- M3["Media 3: Cloud Storage"]
  • 3 copies of your data
  • 2 different storage media
  • 1 offsite copy
# Copy 1: Local incremental
rsync -av --link-dest=/backup/latest \
/var/www/html/ /backup/$(date +%F)/
ln -sfn /backup/$(date +%F) /backup/latest

# Copy 2: Remote server
rsync -avz /backup/$(date +%F)/ user@remote:/backups/$(date +%F)/

# Copy 3: Cloud (using rclone)
rclone sync /backup/$(date +%F)/ s3:my-bucket/backups/$(date +%F)/

Retention Policies

GFS (Grandfather-Father-Son) Rotation

Keep different numbers of daily, weekly, and monthly backups:

LevelKeepFrequency
Daily (Son)7 daysEvery night
Weekly (Father)4 weeksEvery Sunday
Monthly (Grandfather)12 monthsFirst of month

Retention Script

#!/bin/bash
# retention-cleanup.sh — Apply GFS retention policy
BACKUP_DIR="/backup"

# Keep daily backups for 7 days
find "$BACKUP_DIR/daily" -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;

# Keep weekly backups for 4 weeks
find "$BACKUP_DIR/weekly" -maxdepth 1 -type d -mtime +28 -exec rm -rf {} \;

# Keep monthly backups for 12 months
find "$BACKUP_DIR/monthly" -maxdepth 1 -type d -mtime +365 -exec rm -rf {} \;

Simple Rolling Cleanup

# Keep last 14 daily backups
find /backup/ -maxdepth 1 -type d -name "20*" -mtime +14 -exec rm -rf {} \;

Complete Backup Script

#!/bin/bash
# full-backup-strategy.sh — Files + Database + Retention
set -euo pipefail

SOURCE="/var/www/html/"
BACKUP_BASE="/backup"
TIMESTAMP=$(date +%F)
LOG="/var/log/rsync/backup_$TIMESTAMP.log"

mkdir -p "$BACKUP_BASE/$TIMESTAMP" /var/log/rsync

echo "=== Backup started: $(date) ===" >> "$LOG"

# 1. Database export
if command -v mysqldump &>/dev/null; then
mysqldump --defaults-file=/root/.my.cnf \
--single-transaction --routines --triggers \
--all-databases | gzip > "$BACKUP_BASE/$TIMESTAMP/databases.sql.gz"
echo " Database exported" >> "$LOG"
fi

if command -v pg_dumpall &>/dev/null; then
pg_dumpall | gzip > "$BACKUP_BASE/$TIMESTAMP/postgres.sql.gz"
echo " PostgreSQL exported" >> "$LOG"
fi

# 2. Incremental file backup
rsync -av --stats \
--link-dest="$BACKUP_BASE/latest" \
--exclude='cache/' \
--exclude='*.log' \
--exclude='*.tmp' \
"$SOURCE" "$BACKUP_BASE/$TIMESTAMP/files/" \
>> "$LOG" 2>&1

# 3. Update latest symlink
ln -sfn "$BACKUP_BASE/$TIMESTAMP" "$BACKUP_BASE/latest"

# 4. Offsite sync
rsync -avz "$BACKUP_BASE/$TIMESTAMP/" \
user@offsite:/backups/$TIMESTAMP/ >> "$LOG" 2>&1

# 5. Retention cleanup
find "$BACKUP_BASE" -maxdepth 1 -type d -name "20*" -mtime +14 -exec rm -rf {} \;
find /var/log/rsync/ -name "*.log" -mtime +30 -delete

echo "=== Backup complete: $(date) ===" >> "$LOG"

Verifying Backups

warning

A backup you've never tested is not a backup — it's a hope.

Quick Verification

# Compare file counts
find /var/www/html/ -type f | wc -l
find /backup/latest/files/ -type f | wc -l

# Spot-check critical files
diff /var/www/html/.env /backup/latest/files/.env
diff /var/www/html/wp-config.php /backup/latest/files/wp-config.php

Integrity Check with --checksum

# Verify backup matches source (byte-level)
rsync -avnc /var/www/html/ /backup/latest/files/
# No output = perfect match

Scheduled Restore Test

# Monthly: restore to staging and verify
rsync -av /backup/latest/files/ /var/www/staging/
gunzip < /backup/latest/databases.sql.gz | mysql staging_db
# Visit staging site to verify

Common Pitfalls

PitfallConsequencePrevention
Files only, no databaseIncomplete restore — site brokenAlways export DB alongside files
No offsite copyServer failure = total data lossSync to remote server or cloud
No retention policyDisk fills upAutomate cleanup with find -mtime
Never testing restoresDiscover backup is corrupt during emergencyMonthly restore drills
Backing up to same diskDisk failure loses backup tooUse separate drive or remote server

Quick Reference

# Full backup
rsync -av /var/www/ /backup/full-$(date +%F)/

# Incremental backup
rsync -av --link-dest=/backup/latest /var/www/ /backup/$(date +%F)/
ln -sfn /backup/$(date +%F) /backup/latest

# Differential backup
rsync -av --compare-dest=/backup/full-baseline/ /var/www/ /backup/diff-$(date +%F)/

# Verify backup integrity
rsync -avnc /var/www/ /backup/latest/

# Cleanup old backups
find /backup/ -maxdepth 1 -type d -name "20*" -mtime +14 -exec rm -rf {} \;

What's Next