Automatiser ses backups avec un script Bash et cron

Credit : Logo officiel

Automatiser ses backups avec un script Bash et cron

Dylan D. — Agent Support Technique Serveur Linux 1813 mots 10 min de lecture

Automatiser ses backups avec un script Bash et cron

Un client e-commerce m'appelle un dimanche : son VPS Debian a ete attaque, ransomware, tous les fichiers sont chiffres. Sa derniere sauvegarde ? Manuelle, datant de "y a deux mois je crois". Resultat : 8 semaines de commandes perdues, des clients fous, et trois nuits a essayer de recoller les morceaux. Si on avait eu un backup quotidien automatise et synchronise hors-site, on aurait restaure en deux heures et tout le monde aurait dormi.

Pas de backup = pas de filet de securite. Un disque qui lache, une mauvaise manip rm -rf, un ransomware, une suppression accidentelle dans phpMyAdmin : ca arrive. Voici la procedure complete que j'utilise pour mettre en place un systeme de backup automatise, fiable et teste sur les serveurs Debian 12 que je gere.

La regle 3-2-1

Principe inviolable du backup en 2026 :

Le script qu'on va construire couvre les trois niveaux : sauvegarde locale, archivage compresse, sync vers un serveur distant.

Le script complet

Cree /usr/local/bin/backup.sh :

#!/bin/bash
set -euo pipefail
IFS={{CONTENT}}#39;\n\t'

# === CONFIGURATION ===
BACKUP_DIR="/home/backups"
DATE=$(date +%Y-%m-%d_%H%M)
RETENTION_DAYS=14
LOG_FILE="/var/log/backup.log"
LOCK_FILE="/tmp/backup.lock"

# Sites web
WEB_DIR="/var/www"

# Bases de donnees
DB_USER="backup_user"
DB_PASS="$(cat /root/.backup_pass)"
DATABASES=("wordpress_db" "prestashop" "app_production")

# Serveur distant (rsync via SSH)
REMOTE_HOST="backup@stockage.exemple.fr"
REMOTE_DIR="/backups/serveur-ionos"
SSH_KEY="/root/.ssh/backup_key"

# Notifications
NOTIFY_EMAIL="admin@mondomaine.fr"
NOTIFY_WEBHOOK=""   # Optionnel : webhook Slack/Discord

# === FONCTIONS ===
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

error_exit() {
    log "ERREUR : $1"
    notify_failure "$1"
    rm -f "$LOCK_FILE"
    exit 1
}

notify_failure() {
    local message="$1"
    if [ -n "$NOTIFY_EMAIL" ]; then
        echo "$message" | mail -s "[ECHEC] Backup $(hostname) - $DATE" "$NOTIFY_EMAIL"
    fi
    if [ -n "$NOTIFY_WEBHOOK" ]; then
        curl -s -X POST -H 'Content-Type: application/json' \
            -d "{\"text\":\"Backup ECHEC sur $(hostname) : $message\"}" \
            "$NOTIFY_WEBHOOK" > /dev/null
    fi
}

check_space() {
    local available=$(df -BG "$BACKUP_DIR" | tail -1 | awk '{print $4}' | tr -d 'G')
    if [ "$available" -lt 5 ]; then
        error_exit "Espace disque insuffisant (${available}G restants, 5G minimum requis)"
    fi
    log "Espace disque OK : ${available}G disponibles"
}

trap 'rm -f "$LOCK_FILE"' EXIT

# === DEBUT ===
# Verrou anti-execution simultanee
if [ -f "$LOCK_FILE" ]; then
    error_exit "Lock file present, un autre backup est en cours"
fi
touch "$LOCK_FILE"

log "========== Debut du backup $DATE =========="

BACKUP_PATH="$BACKUP_DIR/$DATE"
mkdir -p "$BACKUP_PATH/databases"
mkdir -p "$BACKUP_PATH/files"

check_space

# === BASES DE DONNEES ===
log "Sauvegarde des bases de donnees..."

for DB in "${DATABASES[@]}"; do
    log "  -> Dump de $DB"
    if mysqldump \
        --user="$DB_USER" \
        --password="$DB_PASS" \
        --single-transaction \
        --routines \
        --triggers \
        --events \
        --quick \
        --lock-tables=false \
        --default-character-set=utf8mb4 \
        "$DB" 2>>"$LOG_FILE" | gzip -9 > "$BACKUP_PATH/databases/${DB}.sql.gz"; then

        if [ ${PIPESTATUS[0]} -eq 0 ]; then
            SIZE=$(du -sh "$BACKUP_PATH/databases/${DB}.sql.gz" | cut -f1)
            log "  -> $DB : OK ($SIZE)"
        else
            error_exit "mysqldump $DB a echoue"
        fi
    else
        error_exit "mysqldump $DB a echoue"
    fi
done

# === FICHIERS WEB ===
log "Sauvegarde des fichiers web..."

tar --create --gzip \
    --exclude='*/node_modules' \
    --exclude='*/vendor' \
    --exclude='*/.git' \
    --exclude='*/cache' \
    --exclude='*/wp-content/cache' \
    --exclude='*/wp-content/uploads/cache' \
    --exclude='*/var/cache' \
    --exclude='*/storage/logs' \
    --file="$BACKUP_PATH/files/web.tar.gz" \
    "$WEB_DIR" 2>>"$LOG_FILE"

SIZE=$(du -sh "$BACKUP_PATH/files/web.tar.gz" | cut -f1)
log "  -> Fichiers web : $SIZE"

# === CONFIGS SYSTEME ===
log "Sauvegarde des configurations systeme..."
tar --create --gzip \
    --file="$BACKUP_PATH/files/configs.tar.gz" \
    /etc/nginx/sites-available \
    /etc/nginx/nginx.conf \
    /etc/php \
    /etc/fail2ban/jail.local \
    /etc/fail2ban/filter.d \
    /etc/ssh/sshd_config \
    /etc/letsencrypt \
    /etc/crontab \
    /etc/cron.d \
    /etc/systemd/system \
    2>>"$LOG_FILE" || true

SIZE=$(du -sh "$BACKUP_PATH/files/configs.tar.gz" | cut -f1)
log "  -> Configs : $SIZE"

# === LISTE DES PAQUETS APT ===
log "Liste des paquets installes..."
dpkg --get-selections > "$BACKUP_PATH/files/dpkg-selections.txt"
apt-mark showmanual > "$BACKUP_PATH/files/apt-manual.txt"

# === SYNC DISTANTE ===
if [ -n "$REMOTE_HOST" ]; then
    log "Sync vers serveur distant..."
    if rsync -avz --delete \
        -e "ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new" \
        "$BACKUP_PATH" \
        "$REMOTE_HOST:$REMOTE_DIR/" \
        >> "$LOG_FILE" 2>&1; then
        log "  -> Sync : OK"
    else
        error_exit "Sync rsync vers $REMOTE_HOST a echoue"
    fi
fi

# === ROTATION ===
log "Nettoyage des backups de plus de $RETENTION_DAYS jours..."
DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$RETENTION_DAYS -print | wc -l)
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \;
log "  -> $DELETED ancien(s) backup(s) supprime(s)"

# === RESUME ===
TOTAL_SIZE=$(du -sh "$BACKUP_PATH" | cut -f1)
log "Taille totale : $TOTAL_SIZE"
log "========== Backup termine avec succes =========="

# Notification de succes (optionnel)
if [ -n "$NOTIFY_WEBHOOK" ]; then
    curl -s -X POST -H 'Content-Type: application/json' \
        -d "{\"text\":\"Backup OK sur $(hostname) - $TOTAL_SIZE\"}" \
        "$NOTIFY_WEBHOOK" > /dev/null || true
fi

rm -f "$LOCK_FILE"
exit 0

Le set -euo pipefail au debut c'est crucial. Ca fait planter le script a la moindre erreur au lieu de continuer comme si de rien n'etait. J'ai appris ca a la dure apres un backup qui s'etait execute "avec succes" mais qui avait en fait echoue a chaque etape parce qu'une variable etait vide.

Preparer l'environnement

Permissions et utilisateur MySQL

chmod 700 /usr/local/bin/backup.sh
chown root:root /usr/local/bin/backup.sh
mkdir -p /home/backups
chown root:root /home/backups
chmod 700 /home/backups

Cree un utilisateur MySQL dedie, avec strict minimum de permissions :

CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'MotDePasseUltraSolide!2026';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, RELOAD, REPLICATION CLIENT ON *.* TO 'backup_user'@'localhost';
FLUSH PRIVILEGES;

Stocke le password dans un fichier protege :

echo "MotDePasseUltraSolide!2026" > /root/.backup_pass
chmod 600 /root/.backup_pass

Le script lit ce fichier au lieu d'avoir le password en clair dans le code.

Cle SSH pour le sync distant

ssh-keygen -t ed25519 -f /root/.ssh/backup_key -N ""
ssh-copy-id -i /root/.ssh/backup_key.pub backup@stockage.exemple.fr

# Test
ssh -i /root/.ssh/backup_key backup@stockage.exemple.fr 'mkdir -p /backups/serveur-ionos && echo OK'

Sur le serveur distant, configure une commande forcee pour limiter ce que peut faire la cle :

# /home/backup/.ssh/authorized_keys
command="rsync --server -vlogDtprze.iLsfxC . /backups/serveur-ionos/",no-pty,no-agent-forwarding,no-port-forwarding ssh-ed25519 AAAA...

Comme ca, meme si la cle est compromise, l'attaquant peut juste faire du rsync. Pas de shell, pas de port forwarding.

Planifier avec cron

crontab -e -u root

Ajoute :

# Backup quotidien a 3h du matin (heure creuse)
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Backup hebdomadaire (dimanche 2h) avec snapshot longue duree
0 2 * * 0 /usr/local/bin/backup-weekly.sh >> /var/log/backup.log 2>&1

# Verification mensuelle des backups
0 9 1 * * /usr/local/bin/backup-verify.sh >> /var/log/backup.log 2>&1

Alternative moderne avec systemd timers (cf bases de systemd).

Tester le script

Lance-le manuellement d'abord :

/usr/local/bin/backup.sh
ls -la /home/backups/
tail -50 /var/log/backup.log

Sortie attendue :

# [2026-05-07 14:30:12] ========== Debut du backup 2026-05-07_1430 ==========
# [2026-05-07 14:30:12] Espace disque OK : 78G disponibles
# [2026-05-07 14:30:12] Sauvegarde des bases de donnees...
# [2026-05-07 14:30:18]   -> Dump de wordpress_db
# [2026-05-07 14:30:24]   -> wordpress_db : OK (12M)
# [2026-05-07 14:30:24]   -> Dump de prestashop
# [2026-05-07 14:30:42]   -> prestashop : OK (45M)
# [2026-05-07 14:30:42] Sauvegarde des fichiers web...
# [2026-05-07 14:32:18]   -> Fichiers web : 1.2G
# [2026-05-07 14:32:18] Sync vers serveur distant...
# [2026-05-07 14:35:42]   -> Sync : OK
# [2026-05-07 14:35:42] Nettoyage des backups de plus de 14 jours...
# [2026-05-07 14:35:42]   -> 1 ancien(s) backup(s) supprime(s)
# [2026-05-07 14:35:42] Taille totale : 1.3G
# [2026-05-07 14:35:42] ========== Backup termine avec succes ==========

Verifier l'integrite des sauvegardes

C'est le truc que tout le monde oublie : tester regulierement la restauration. Une sauvegarde jamais testee, c'est pas une sauvegarde.

Cree /usr/local/bin/backup-verify.sh :

#!/bin/bash
set -euo pipefail

BACKUP_DIR="/home/backups"
LATEST=$(ls -1t "$BACKUP_DIR" | head -1)
BACKUP_PATH="$BACKUP_DIR/$LATEST"

echo "=== Verification du backup $LATEST ==="

# Verifier les dumps SQL
for sql in "$BACKUP_PATH/databases/"*.sql.gz; do
    if gunzip -t "$sql" 2>/dev/null; then
        size=$(stat -c%s "$sql")
        echo "  OK : $(basename $sql) ($size octets)"
    else
        echo "  FAIL : $(basename $sql) corrompu"
        exit 1
    fi
done

# Verifier les archives tar
for tar in "$BACKUP_PATH/files/"*.tar.gz; do
    if tar -tzf "$tar" > /dev/null 2>&1; then
        files=$(tar -tzf "$tar" | wc -l)
        echo "  OK : $(basename $tar) ($files fichiers)"
    else
        echo "  FAIL : $(basename $tar) corrompu"
        exit 1
    fi
done

echo "=== Backup $LATEST OK ==="

Lance-le mensuellement, voire fais une vraie restauration test sur un VPS de test une fois par trimestre.

Procedure de restauration

A documenter et a tester regulierement. Exemple pour MariaDB :

# Restaurer une base
gunzip < /home/backups/2026-05-07_0300/databases/wordpress_db.sql.gz | mysql -u root wordpress_db

# Restaurer les fichiers web
cd /
tar -xzf /home/backups/2026-05-07_0300/files/web.tar.gz

# Restaurer les configs
cd /
tar -xzf /home/backups/2026-05-07_0300/files/configs.tar.gz

# Recharger les services
systemctl reload nginx php8.3-fpm fail2ban

Erreurs courantes et leur fix

mysqldump: Got error 1226: 'User has exceeded the 'max_questions' resource'

Cause : limite de requetes par heure pour l'utilisateur backup.

Fix :

ALTER USER 'backup_user'@'localhost' WITH MAX_QUERIES_PER_HOUR 0;
FLUSH PRIVILEGES;

tar: Removing leading '/' from member names puis fichier vide

Cause : tu lances tar sans --absolute-names mais tu as un dossier / au debut.

Fix : c'est juste un warning, pas une erreur. Ignore-le ou redirige stderr vers le log.

rsync: connection unexpectedly closed

Cause : la connexion SSH coupe (timeout, firewall, serveur distant inaccessible).

Fix :

# Tester la connectivite
ssh -i /root/.ssh/backup_key backup@stockage.exemple.fr 'echo OK'
# Augmenter les timeouts dans le script :
rsync --timeout=600 -e "ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=10 ..."

Le script s'execute deux fois et se marche dessus

Cause : le backup precedent n'a pas fini avant que cron relance.

Fix : le verrou LOCK_FILE du script empeche ca. Verifie qu'il est bien implemente. Sinon flock :

0 3 * * * flock -n /tmp/backup.lock /usr/local/bin/backup.sh

Disk full en plein backup

Cause : tu as moins d'espace que la taille du backup, et le script a echoue la verification preliminaire.

Fix : ajuste RETENTION_DAYS plus bas, externalise plus tot, ou augmente le disque. Et la fonction check_space t'aurait alerte avant si tu l'as bien implementee.

Pour aller plus loin

Le filet de securite indispensable

Un script de backup automatise, redonde hors-site, et teste regulierement, c'est ce qui te permet de dormir tranquille meme quand le pire arrive. Investis quelques heures pour mettre ca en place et tu te remercieras chaque fois qu'il y aura une emmerde. Et n'oublie pas la regle d'or : un backup non teste, c'est pas un backup. Automatise oui, mais verifie. Toujours.

# Articles similaires

Sur les memes sujets et plus loin