Backups are not optional — they are the last line of defense against hardware failure, ransomware, accidental deletion, and human error. The question is not whether to back up but how: what to back up, how often, where to store it, and critically — how to verify you can actually restore from it.
This page covers backup strategy fundamentals: full vs incremental vs differential, the 3-2-1 rule, tar and rsync patterns, and what to include in every backup plan. Part 2 covers rsync in depth. Part 3 covers recovery procedures when things go wrong.
| Type | What it backs up | Speed | Restore | Best for |
|---|---|---|---|---|
| Full | Everything every time | Slow | Single file needed | Weekly anchor backup |
| Incremental | Changes since last backup (any type) | Fast | Full + all incrementals | Daily backups |
| Differential | Changes since last FULL backup | Medium | Full + latest differential | Balance of speed and simplicity |
# Create a compressed archive tar -czf backup-$(date +%Y%m%d).tar.gz /var/www/html/ tar -czf /backup/etc-$(date +%Y%m%d).tar.gz /etc/ # Create with bzip2 compression (smaller, slower) tar -cjf backup-$(date +%Y%m%d).tar.bz2 /var/www/html/ # Create with xz compression (smallest, slowest) tar -cJf backup-$(date +%Y%m%d).tar.xz /var/www/html/ # List contents without extracting tar -tzf backup-20260425.tar.gz tar -tzf backup-20260425.tar.gz | grep "nginx.conf" # Extract entire archive tar -xzf backup-20260425.tar.gz # Extract to a specific directory tar -xzf backup-20260425.tar.gz -C /restore/ # Extract a single file tar -xzf backup-20260425.tar.gz var/www/html/index.html # Show progress during creation tar -czf backup.tar.gz /var/www/html/ | pv -l > /dev/null
tar -czf backup.tar.gz \
--exclude='/var/www/html/cache' \
--exclude='*.tmp' \
--exclude='*.log' \
/var/www/html/
Excluding cache directories, temp files, and logs keeps archives
small and fast without losing anything important.
# Basic rsync backup (local) rsync -av /var/www/html/ /backup/www/ # rsync with delete (mirror -- removes files deleted from source) rsync -av --delete /var/www/html/ /backup/www/ # rsync to a remote server over SSH rsync -avz /var/www/html/ user@backupserver:/backup/www/ # rsync from remote to local (pull backup) rsync -avz user@webserver:/var/www/html/ /backup/www/ # Dry run -- show what would be transferred without doing it rsync -avnz --delete /var/www/html/ /backup/www/ # Key flags: # -a archive mode (recursive, preserves permissions, timestamps, symlinks) # -v verbose # -z compress during transfer (good for slow links) # -n dry run # --delete remove files from destination not in source # --progress show per-file progress # --stats show summary statistics
#!/bin/bash # backup-essentials.sh -- back up everything that matters DEST=/backup DATE=$(date +%Y%m%d) LOG=/var/log/backup.log log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a $LOG; } log "=== Backup started ===" # /etc -- ALL system configuration log "Backing up /etc" tar -czf $DEST/etc-$DATE.tar.gz /etc/ 2>>/var/log/backup-errors.log # Home directories log "Backing up /home" tar -czf $DEST/home-$DATE.tar.gz /home/ 2>>/var/log/backup-errors.log # Web content log "Backing up web content" rsync -az --delete /var/www/ $DEST/www/ 2>>/var/log/backup-errors.log # Databases -- dump before backing up files log "Dumping databases" mysqldump --all-databases | gzip > $DEST/mysql-$DATE.sql.gz 2>>/var/log/backup-errors.log pg_dumpall | gzip > $DEST/postgres-$DATE.sql.gz 2>>/dev/null # Custom application data log "Backing up application data" rsync -az /opt/myapp/data/ $DEST/appdata/ 2>>/var/log/backup-errors.log # Crontabs crontab -l > $DEST/crontab-root-$DATE.txt 2>/dev/null for user in $(cut -f1 -d: /etc/passwd); do crontab -l -u $user > $DEST/crontab-$user-$DATE.txt 2>/dev/null done log "=== Backup completed ==="
mysqldump or pg_dump to get a
consistent snapshot, then back up the dump file.
Hard-link snapshots give you multiple point-in-time backups that look like full backups but use the space of incrementals. This is the technique behind Time Machine and many professional backup systems:
# The --link-dest trick -- files unchanged since yesterday are hard-linked # instead of copied. Space used = only the changed files. BACKUP_ROOT=/backup/snapshots TODAY=$(date +%Y%m%d) YESTERDAY=$(date -d yesterday +%Y%m%d) rsync -av --delete \ --link-dest=$BACKUP_ROOT/$YESTERDAY \ /var/www/html/ \ $BACKUP_ROOT/$TODAY/ # Result: each day's directory looks like a full backup # but unchanged files are just hard links -- no extra disk space ls $BACKUP_ROOT/ # 20260420/ 20260421/ 20260422/ 20260423/ 20260424/ 20260425/ # Each directory appears to contain all files # But total disk usage is only slightly more than one full backup # Clean up snapshots older than 30 days find $BACKUP_ROOT -maxdepth 1 -type d -mtime +30 -exec rm -rf {} \;
# Verify tar archive integrity tar -tzf backup-20260425.tar.gz > /dev/null echo "Archive OK: $?" # Verify and list files tar -tvzf backup-20260425.tar.gz | wc -l echo "Files in archive: $(tar -tzf backup-20260425.tar.gz | wc -l)" # Test extraction to a temp directory mkdir -p /tmp/restore-test tar -xzf backup-20260425.tar.gz -C /tmp/restore-test ls -la /tmp/restore-test/ rm -rf /tmp/restore-test # Verify rsync backup matches source rsync -avnc /var/www/html/ /backup/www/ 2>&1 | grep -v "^sending\|^sent\|^total" # -c = checksum verification (slower but thorough) # Check backup file sizes are reasonable ls -lh /backup/*.tar.gz du -sh /backup/www/ # Monthly restore test -- actually restore something #!/bin/bash # Pick a random file, restore it, verify it matches original TEST_FILE=$(tar -tzf /backup/etc-$(date +%Y%m%d).tar.gz | shuf -n 1) tar -xzf /backup/etc-$(date +%Y%m%d).tar.gz -C /tmp/restore-test "$TEST_FILE" diff /$TEST_FILE /tmp/restore-test/$TEST_FILE && echo "RESTORE VERIFIED" || echo "RESTORE FAILED"
# rclone -- sync to cloud storage (S3, GCS, Backblaze, etc.) sudo dnf install -y rclone # or download from rclone.org # Configure rclone (interactive) rclone config # Sync backup directory to S3 rclone sync /backup/ s3:mybucket/server-backups/ # Copy with progress rclone copy --progress /backup/etc-$(date +%Y%m%d).tar.gz s3:mybucket/ # Encrypt before uploading (always encrypt offsite backups) gpg --symmetric --cipher-algo AES256 backup-20260425.tar.gz rclone copy backup-20260425.tar.gz.gpg s3:mybucket/encrypted/ # rsync to an offsite server rsync -avz --delete /backup/ offsite-user@offsite-server:/backup/$(hostname)/ # sftp/scp for simple file transfers scp /backup/etc-$(date +%Y%m%d).tar.gz user@offsite:/backup/
#!/bin/bash # backup-retention.sh -- implement grandfather-father-son retention BACKUP_DIR=/backup DATE=$(date +%Y%m%d) DOW=$(date +%u) # 1=Monday, 7=Sunday DOM=$(date +%d) # day of month # Son: daily backups, keep 7 days tar -czf $BACKUP_DIR/daily/backup-$DATE.tar.gz /var/www/html/ find $BACKUP_DIR/daily/ -name "backup-*.tar.gz" -mtime +7 -delete # Father: weekly backup on Sunday, keep 4 weeks if [[ "$DOW" == "7" ]]; then cp $BACKUP_DIR/daily/backup-$DATE.tar.gz $BACKUP_DIR/weekly/ find $BACKUP_DIR/weekly/ -name "backup-*.tar.gz" -mtime +28 -delete fi # Grandfather: monthly backup on 1st, keep 12 months if [[ "$DOM" == "01" ]]; then cp $BACKUP_DIR/daily/backup-$DATE.tar.gz $BACKUP_DIR/monthly/ find $BACKUP_DIR/monthly/ -name "backup-*.tar.gz" -mtime +365 -delete fi echo "Backup complete: $(du -sh $BACKUP_DIR)"
#!/bin/bash # backup-health.sh -- verify backups ran and are fresh BACKUP_DIR=/backup MAX_AGE_HOURS=25 # alert if backup is older than this MIN_SIZE_MB=10 # alert if backup is smaller than this (suspiciously small) ALERT_EMAIL=admin@example.com alert() { echo "$*" | mail -s "BACKUP ALERT: $(hostname)" $ALERT_EMAIL; echo "ALERT: $*"; } echo "=== Backup Health Check: $(hostname) ===" echo "=== $(date) ===" # Check each expected backup file for pattern in "daily/backup-*.tar.gz" "www/" "mysql-*.sql.gz"; do LATEST=$(ls -t $BACKUP_DIR/$pattern 2>/dev/null | head -1) if [[ -z "$LATEST" ]]; then alert "Missing backup: $pattern not found" continue fi # Check age AGE_HOURS=$(( ( $(date +%s) - $(stat -c %Y "$LATEST") ) / 3600 )) if [[ $AGE_HOURS -gt $MAX_AGE_HOURS ]]; then alert "Stale backup: $LATEST is ${AGE_HOURS}h old (max ${MAX_AGE_HOURS}h)" else echo "OK ($AGE_HOURS h old): $LATEST" fi # Check size SIZE_MB=$(du -sm "$LATEST" | awk '{print $1}') if [[ $SIZE_MB -lt $MIN_SIZE_MB ]]; then alert "Suspiciously small backup: $LATEST is only ${SIZE_MB}MB" fi done echo "Backup dir usage: $(du -sh $BACKUP_DIR)"
| Command | What it does |
|---|---|
| tar -czf backup.tar.gz /path/ | Create compressed archive |
| tar -tzf backup.tar.gz | List archive contents |
| tar -xzf backup.tar.gz -C /restore/ | Extract to directory |
| rsync -av --delete src/ dest/ | Mirror with deletion |
| rsync -avz src/ user@host:/dest/ | Remote backup over SSH |
| rsync -avnz src/ dest/ | Dry run |
| rsync --link-dest=PREV src/ TODAY/ | Hard-link snapshot backup |
| mysqldump --all-databases | gzip > db.sql.gz | Dump all MySQL databases |
| pg_dumpall | gzip > pg.sql.gz | Dump all PostgreSQL databases |
| gpg --symmetric file.tar.gz | Encrypt backup file |
| rclone sync /backup/ s3:bucket/ | Sync to cloud storage |