Backups are a bit like home insurance: nobody takes them seriously until the day the hard drive dies at 3 a.m., a botched update corrupts the database, or worse, ransomware encrypts the entire production server. On that day, the question is no longer "do I have a backup?" but "is my backup actually usable?". Spoiler: in many cases, the answer is no.
This article lays the foundations of a solid backup strategy for commandes Linux servers, starting from the 3-2-1 rule and drilling down to concrete commands. No empty theory: code, configuration files, and battle-tested production practices.
Understanding the 3-2-1 rule
The 3-2-1 rule is the cornerstone of any serious backup strategy. The principle is simple:
- 3 copies of your data (the original + 2 backups)
- 2 different media (local disk + NAS, or SSD + magnetic tape)
- 1 off-site copy (another datacenter, a remote server, or object storage such as S3)
The idea behind this rule is to protect yourself against different types of disaster. A hard drive can die, but it is statistically unlikely that two disks on two different media will die at the same time. And if your server room burns down, the off-site copy saves you.
A backup that exists only on the same disk as the original data is not a backup. It is an illusion of security.
In practice, here is an example of a 3-2-1 architecture for a typical web server:
- Copy 1: the live data on the server (SSD disk)
- Copy 2: a daily backup to a local NAS via rsync
- Copy 3: a weekly encrypted backup to a remote server via borgbackup
rsync: the foundation of every strategy
rsync is the fundamental backup tool on Linux. It synchronizes files incrementally, transferring only the differences, which makes it fast and bandwidth-efficient.
The essential options
# Basic local backup
rsync -avz --delete /var/www/ /mnt/backup/www/
# Remote backup over SSH
rsync -avz --delete -e "ssh -p 22" /var/www/ user@backup-server:/backup/www/
# With exclusions (logs, cache, temporary files)
rsync -avz --delete \
--exclude='*.log' \
--exclude='.cache/' \
--exclude='tmp/' \
/var/www/ /mnt/backup/www/
Let's break down the most important options:
- -a (archive): preserves permissions, owners, timestamps, symbolic links
- -v (verbose): displays the files being transferred
- -z (compress): compresses the data during transfer
- --delete: removes from the destination any files that no longer exist on the source
- --dry-run: simulates the operation without changing anything (essential for testing)
A robust rsync backup script
#!/bin/bash
# backup-rsync.sh - Daily backup with rsync
set -euo pipefail
BACKUP_SRC="/var/www /etc /home"
BACKUP_DST="/mnt/nas/backups/$(hostname)"
LOG_FILE="/var/log/backup-rsync.log"
DATE=$(date +%Y-%m-%d_%H%M)
exec >> "$LOG_FILE" 2>&1
echo "=== Backup started: $DATE ==="
for src in $BACKUP_SRC; do
dir_name=$(basename "$src")
mkdir -p "${BACKUP_DST}/${dir_name}"
rsync -avz --delete \
--exclude='*.log' \
--exclude='*.tmp' \
--exclude='.cache/' \
"$src/" "${BACKUP_DST}/${dir_name}/"
echo "[OK] $src backed up"
done
echo "=== Backup finished: $(date +%Y-%m-%d_%H%M) ==="
The set -euo pipefail option is crucial: it makes the script fail at the first error instead of silently carrying on. A backup script that fails without warning is worse than a script that does not exist at all.
Incremental backups with rsnapshot
rsnapshot takes the rsync concept further by automatically handling backup rotation and retention via hard links. The result: each snapshot looks like a full copy, but only consumes the space of the files that changed.
Installation and configuration
# Installation
sudo apt install rsnapshot # Debian/Ubuntu
sudo dnf install rsnapshot # Fedora/RHEL
# Main configuration file
sudo nano /etc/rsnapshot.conf
Warning: rsnapshot uses tabs as separators in its configuration file, not spaces. This is the number-one source of errors.
# /etc/rsnapshot.conf (separators = tabs!)
config_version 1.2
snapshot_root /mnt/backup/rsnapshot/
cmd_cp /bin/cp
cmd_rm /bin/rm
cmd_rsync /usr/bin/rsync
cmd_ssh /usr/bin/ssh
cmd_logger /usr/bin/logger
# Retention levels
retain hourly 6
retain daily 7
retain weekly 4
retain monthly 6
# Directories to back up
backup /var/www/ localhost/
backup /etc/ localhost/
backup /home/ localhost/
# Remote server over SSH
backup user@remote-server:/var/www/ remote/
Scheduling with cron
# /etc/cron.d/rsnapshot
0 */4 * * * root /usr/bin/rsnapshot hourly
30 3 * * * root /usr/bin/rsnapshot daily
0 3 * * 1 root /usr/bin/rsnapshot weekly
30 2 1 * * root /usr/bin/rsnapshot monthly
Always check your configuration before running it:
# Test the configuration (does nothing, checks the syntax)
rsnapshot configtest
# Simulate an hourly backup
rsnapshot -t hourly
Backing up databases
Copying the files of a running database is a very bad idea. The files may be in an inconsistent state. You must use the dump tools provided by the DBMS.
MySQL / MariaDB
#!/bin/bash
# backup-mysql.sh
set -euo pipefail
BACKUP_DIR="/mnt/backup/mysql"
DATE=$(date +%Y-%m-%d)
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
# Dump all databases
mysqldump --all-databases \
--single-transaction \
--routines \
--triggers \
--events \
--quick \
| gzip > "${BACKUP_DIR}/all-databases_${DATE}.sql.gz"
# Remove old dumps
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +${RETENTION_DAYS} -delete
echo "[OK] MySQL dump finished: $(du -h ${BACKUP_DIR}/all-databases_${DATE}.sql.gz)"
The --single-transaction option is essential for InnoDB tables: it guarantees a consistent dump without locking the tables.
PostgreSQL
#!/bin/bash
# backup-postgresql.sh
set -euo pipefail
BACKUP_DIR="/mnt/backup/postgresql"
DATE=$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"
# Dump all databases with roles and tablespaces
pg_dumpall --clean \
| gzip > "${BACKUP_DIR}/pg_dumpall_${DATE}.sql.gz"
# Alternative: dump database by database in custom format (selective restore)
for db in $(psql -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;"); do
pg_dump -Fc "$db" > "${BACKUP_DIR}/${db}_${DATE}.dump"
done
echo "[OK] PostgreSQL dump finished"
pg_dump's custom format (-Fc) allows selective restoration of individual tables, which is invaluable when you only want to restore part of the database.
Encryption and backup security
An unencrypted backup on a remote server is a major security risk. If the backup server is compromised, all your data is compromised too. Two complementary approaches exist.
Encryption with GPG
# Encrypt a database dump
gpg --symmetric --cipher-algo AES256 \
--output backup_2026-02-08.sql.gz.gpg \
backup_2026-02-08.sql.gz
# Decrypt
gpg --decrypt backup_2026-02-08.sql.gz.gpg > backup_2026-02-08.sql.gz
# In an automated script (passphrase via a secure file)
gpg --batch --yes --passphrase-file /root/.backup-passphrase \
--symmetric --cipher-algo AES256 \
--output "${BACKUP_DIR}/dump_${DATE}.sql.gz.gpg" \
"${BACKUP_DIR}/dump_${DATE}.sql.gz"
The passphrase file must have strict permissions: chmod 600 /root/.backup-passphrase.
borgbackup: the all-in-one solution
borgbackup combines deduplication, compression and encryption. It is the ideal tool for off-site backups because it minimizes the bandwidth and disk space used.
# Initialize an encrypted repository
borg init --encryption=repokey user@backup-srv:/backup/borg-repo
# Create a backup
borg create --stats --progress \
--compression zstd,6 \
user@backup-srv:/backup/borg-repo::{hostname}-{now:%Y-%m-%d_%H%M} \
/var/www /etc /home \
--exclude '*.log' \
--exclude '.cache'
# Automatic retention policy
borg prune --stats \
--keep-daily=7 \
--keep-weekly=4 \
--keep-monthly=12 \
user@backup-srv:/backup/borg-repo
restic: the modern alternative
# Initialize a repository (supports S3, SFTP, local, etc.)
restic -r sftp:user@backup-srv:/backup/restic-repo init
# Back up
restic -r sftp:user@backup-srv:/backup/restic-repo backup \
/var/www /etc /home \
--exclude="*.log" \
--exclude=".cache"
# Verify integrity
restic -r sftp:user@backup-srv:/backup/restic-repo check
restic has the advantage of natively supporting many storage backends (S3, Azure Blob, Google Cloud Storage, SFTP) without any extra configuration.
Automation and scheduling
A manual backup is a forgotten backup. Automation is non-negotiable.
Cron: the classic method
# /etc/cron.d/backup-strategy
# Database dumps every day at 2:00 a.m.
0 2 * * * root /opt/scripts/backup-mysql.sh
# rsync to NAS every day at 3:00 a.m.
0 3 * * * root /opt/scripts/backup-rsync.sh
# borg to remote server every Sunday at 4:00 a.m.
0 4 * * 0 root /opt/scripts/backup-borg.sh
systemd timers: the modern method
systemd timers offer several advantages over cron: built-in logging, dependency management, and execution of missed tasks at the next boot.
# /etc/systemd/system/backup-daily.service
[Unit]
Description=Daily backup
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup-rsync.sh
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/backup-daily.timer
[Unit]
Description=Daily backup schedule
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
# Enable the timer
sudo systemctl enable --now backup-daily.timer
# Check the status
systemctl list-timers --all | grep backup
# View the logs of the last run
journalctl -u backup-daily.service --since today
The Persistent=true option is particularly useful: if the server was off at the scheduled time, the backup will run at the next boot. With cron, it would simply be lost.
Wrapper script with notifications
#!/bin/bash
# backup-wrapper.sh - Backup orchestrator with alerts
set -euo pipefail
LOG_FILE="/var/log/backup-$(date +%Y-%m-%d).log"
ALERT_EMAIL="[email protected]"
HOSTNAME=$(hostname -f)
exec >> "$LOG_FILE" 2>&1
backup_status=0
echo "=== Full backup started: $(date) ==="
# Step 1: Database dumps
/opt/scripts/backup-mysql.sh || backup_status=1
# Step 2: Local synchronization
/opt/scripts/backup-rsync.sh || backup_status=1
# Step 3: Encrypted off-site backup
/opt/scripts/backup-borg.sh || backup_status=1
echo "=== Full backup finished: $(date) ==="
# Notification on failure
if [ $backup_status -ne 0 ]; then
mail -s "[ALERT] Backup failed on ${HOSTNAME}" "$ALERT_EMAIL" < "$LOG_FILE"
exit 1
fi
Testing your restores
Here is the truth nobody wants to hear: a backup that has never been tested by restoring it is not a backup. It is a file that takes up disk space and gives you a false sense of security.
Restore test procedure
#!/bin/bash
# test-restore.sh - Monthly backup verification
set -euo pipefail
RESTORE_DIR="/tmp/restore-test-$(date +%Y%m%d)"
mkdir -p "$RESTORE_DIR"
echo "=== Restore test: $(date) ==="
# Test 1: Restore files from rsnapshot
echo "[TEST] rsnapshot restore..."
cp -a /mnt/backup/rsnapshot/daily.0/localhost/var/www/ \
"${RESTORE_DIR}/www-restore/"
diff -rq /var/www/ "${RESTORE_DIR}/www-restore/" \
&& echo "[OK] Web files intact" \
|| echo "[ERROR] Differences detected"
# Test 2: Restore MySQL database
echo "[TEST] MySQL restore..."
LATEST_DUMP=$(ls -t /mnt/backup/mysql/all-databases_*.sql.gz | head -1)
gunzip -c "$LATEST_DUMP" | mysql --database=test_restore \
&& echo "[OK] MySQL dump usable" \
|| echo "[ERROR] MySQL dump corrupted"
# Test 3: borg integrity check
echo "[TEST] borg check..."
borg check user@backup-srv:/backup/borg-repo \
&& echo "[OK] borg repository intact" \
|| echo "[ERROR] borg repository corrupted"
# Cleanup
rm -rf "$RESTORE_DIR"
echo "=== End of tests: $(date) ==="
Schedule this script once a month. Add it to your systemd timers or your crontab. The day you need a real restore, you will know for certain that your backups work.
Essential checkpoints
- Integrity: the file is not corrupted (checksums, borg check, restic check)
- Completeness: all the expected data is present
- Usability: the dump actually restores into a working database
- Time: how long does a full restore take? (RTO)
- Freshness: what is the date of the last successful backup? (RPO)
Summary and rollout
Here is a summary of the complete strategy to put in place:
- 3-2-1 rule: 3 copies, 2 media, 1 off-site. Non-negotiable.
- File data: rsync for synchronization, rsnapshot for rotation with retention.
- Databases: dedicated dumps with mysqldump or pg_dump, never a copy of raw files.
- Encryption: borgbackup or restic for off-site, GPG for sensitive dumps.
- Automation: systemd timers preferably, cron as a fallback. Failure notifications mandatory.
- Monthly tests: actually restoring the data, not just checking a file size.
Start with the simplest thing: a daily rsync script to a second disk. Then progressively add layers of complexity. An imperfect backup strategy that is actually in place is infinitely better than a perfect strategy that stays in a planning document.
Comments