Linux Backup Strategies: The 3-2-1 Rule in Practice

A complete guide to setting up a robust backup strategy on a Linux server with rsync, rsnapshot, borgbackup and cron automation.

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.

Did you enjoy this article?

Comments

Morgann Riu

Cybersecurity and Linux administration expert. I help companies secure and optimize their critical infrastructures.

Back to the blog

Checklist Sécurité Linux

30 points essentiels pour sécuriser un serveur Linux. Recevez aussi les nouveaux tutoriels par email.

Pas de spam. Désabonnement en 1 clic.