Credit : Logo officiel
Automatiser ses backups avec un script Bash et cron
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 :
- 3 copies de tes donnees au minimum
- 2 supports de stockage differents (disque local + stockage distant)
- 1 copie hors-site (un autre datacenter, S3, un Hetzner Storage Box...)
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
- Sauvegarder et restaurer une base MySQL : approfondir les options mysqldump et la restauration.
- Migrer un site WordPress sans temps d'arret : utiliser ces backups dans un workflow de migration.
- Bases de systemd : timers et services : remplacer cron par des timers systemd.
- Securiser SSH avec sshd_config : indispensable pour les sync rsync via SSH.
- Migrer WordPress avec BackWPup : alternative pour des backups WordPress specifiquement.
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.