tva
← Analyses

Reprise après sinistre pour les services auto-hébergés : notre stratégie de sauvegarde

L’auto-hébergement vous donne un contrôle que les services gérés n’offrent pas : vous choisissez le matériel, le réseau, la résidence des données et la structure des coûts. Mais en réalité, ce contrôle s’accompagne d’une responsabilité que les services gérés assument discrètement : lorsque quelque chose tourne mal, c’est entièrement votre problème. Pour une configuration sur un seul serveur exécutant plusieurs services en production – Supabase, un CRM, un DAM, un serveur Git – la stratégie de sauvegarde et de récupération n’est pas une préoccupation secondaire. C’est la différence entre un incident dont on peut se remettre et une perte de données permanente.

Cet article documente la stratégie de sauvegarde que nous utilisons pour un environnement auto-hébergé en production. Les services concernés sont une pile Supabase (PostgreSQL comme système principal de stockage des données), un CRM auto-hébergé (Twenty, qui utilise également PostgreSQL), un serveur Git Gitea et un système de gestion des actifs numériques. Tous s’exécutent dans des conteneurs Docker sur un seul VPS Hetzner. La stratégie doit tenir compte de tous sans créer de périodes de charge d’E/S inacceptables ni de dépendances d’orchestration complexes.

Pourquoi un serveur unique accroît les risques

Une configuration sur un seul serveur n’a aucune redondance intégrée. Il n’y a pas de réplique en attente, pas de basculement entre plusieurs zones de disponibilité, pas de restauration automatique de base de données déclenchée par un contrôle d’état. Lorsque le serveur tombe en panne – que ce soit en raison d’une défaillance matérielle, d’une mise à niveau ratée, d’un problème de stockage ou d’un incident de sécurité – la seule possibilité de reprise passe par une sauvegarde. Si la sauvegarde est incomplète, périmée ou non testée, les possibilités de reprise sont limitées en conséquence.

Il s’agit d’un modèle de menace différent de celui d’un service cloud géré, où la défaillance matérielle est largement invisible car le fournisseur d’infrastructure gère le basculement. Sur un seul VPS, vous planifiez explicitement les modes de défaillance dont les services gérés vous dispensent de vous préoccuper. La planification n’est pas particulièrement complexe, mais elle doit être réalisée avant l’incident plutôt que pendant.

Ce qui doit être sauvegardé

Chaque service a son propre profil de données. Comprendre quelles données sont irremplaçables et où elles résident est la condition préalable à la conception de la stratégie de sauvegarde.

Supabase stocke ses données dans une instance PostgreSQL gérée par la pile Supabase. La base de données contient toutes les données d’application, les enregistrements des utilisateurs et les métadonnées d’authentification et de stockage de Supabase lui-même. Les fichiers téléversés dans les compartiments de stockage sont conservés sur disque et nécessitent un traitement séparé de la sauvegarde de la base de données.

Twenty CRM s’appuie sur sa propre instance PostgreSQL, séparée de celle de Supabase. Il contient des enregistrements de contacts, des données d’opportunités et l’état des flux de travail. Cette base de données est plus petite en volume mais très sensible – perdre des données CRM est plus perturbant pour les opérations que perdre des données d’application qui peuvent être recréées.

Gitea stocke les dépôts sur disque sous forme d’objets Git dans des dépôts nus. Les données du dépôt sont en principe reconstructibles à partir des postes de travail des développeurs (puisqu’un clone complet, non superficiel, contient l’historique qu’il a récupéré, mais pas les objets Git LFS ni les références qu’il n’a jamais récupérées), mais les données du suivi des tickets, les commentaires de pull requests et la configuration de l’équipe ne sont stockés que dans la base de données de Gitea et ne sont présents dans aucun clone. Les objets Git et la base de données Gitea doivent tous deux être sauvegardés.

DAM (gestion des actifs numériques) stocke les fichiers originaux, les versions dérivées après traitement et les métadonnées. Les fichiers originaux sont la partie irremplaçable ; les dérivés peuvent être régénérés. Les métadonnées résident dans une base de données qui enregistre les relations entre fichiers, les étiquettes et les droits d’utilisation.

Orchestration de pg_dump

L’outil pg_dump de PostgreSQL est le bon outil pour les sauvegardes logiques de base de données. Il exporte une représentation SQL de la base de données qui peut être restaurée sur n’importe quelle version compatible de PostgreSQL, ce qui est plus portable que les sauvegardes physiques (qui nécessitent la même version de PostgreSQL et la même organisation binaire).

Le script de sauvegarde pour chaque instance PostgreSQL suit le même modèle :

#!/usr/bin/env bash
set -euo pipefail

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

mkdir -p "${BACKUP_DIR}"

docker exec "${CONTAINER_NAME}"   pg_dump -U postgres -d "${DB_NAME}"   | gzip -9 > "${OUTPUT_FILE}"

echo "Sauvegarde terminée : ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"

La directive set -euo pipefail au début est important : il interrompt immédiatement le script si une commande échoue, y compris les commandes dans un pipeline. Sans pipefail, un échec de pg_dump suivi d’une réussite de gzip laisserait un fichier compressé vide ou tronqué (le message d’erreur est envoyé à stderr, pas dans le fichier), alors que le script signalerait tout de même une réussite ; le problème n’apparaîtrait qu’au moment de la restauration.

La sortie est compressée avec gzip -9 au fil du traitement plutôt qu’en post-traitement. Cela limite l’espace disque utilisé et évite d’écrire d’abord une exportation non compressée sur disque, ce qui est important sur les serveurs où l’espace libre doit être géré avec soin.

Planification décalée

Exécuter toutes les tâches de sauvegarde simultanément créerait une période de contention d’E/S qui pourrait dégrader les services sauvegardés. Une instance Supabase sollicitée ne gagne rien à être en concurrence avec pg_dump pour les E/S disque. La solution est la planification décalée – chaque tâche de sauvegarde commence à un moment différent, avec un espacement suffisant pour permettre à la tâche précédente de se terminer avant le début de la suivante.

Le calendrier que nous utilisons, exprimé en entrées cron :

# Sauvegarde PostgreSQL Supabase — quotidien à 02h00
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db

# Sauvegarde PostgreSQL Twenty CRM — quotidien à 02h30
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db

# Sauvegarde base de données Gitea — quotidien à 03h00
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db

# Sauvegarde base de données DAM — quotidien à 03h30
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db

# Objets de dépôt Gitea — quotidien à 04h00
0 4 * * * /opt/scripts/git-objects-backup.sh

# Fichiers de stockage (Supabase + originaux DAM) — quotidien à 04h30
30 4 * * * /opt/scripts/files-backup.sh

Les intervalles de 30 minutes entre les tâches prévoient une marge de sécurité – la plupart des sauvegardes de bases de données se terminent en quelques minutes pour des bases de données de cette taille. Mais les intervalles tiennent également compte de l’étape de téléversement qui suit chaque sauvegarde : le fichier compressé est envoyé vers le stockage objet avant que la tâche suivante commence, de sorte que le disque local n’accumule pas plusieurs jours de sauvegardes simultanément.

Téléversement vers le stockage objet et conservation

Les sauvegardes qui n’existent que sur le même serveur que les services qu’elles protègent ne sont pas des sauvegardes – ce sont des instantanés qui seront perdus dans le même incident qui détruit les données qu’ils protègent. Chaque sauvegarde doit être envoyée vers un emplacement de stockage séparé avant que la copie locale puisse être considérée comme complète.

Nous utilisons un fournisseur de stockage objet compatible S3 (séparé du fournisseur VPS) et envoyons les sauvegardes avec rclone, qui gère automatiquement les nouvelles tentatives et la vérification des sommes de contrôle après le transfert :

rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/"   --checksum   --transfers 1   --log-level INFO

L’option --checksum demande à rclone de comparer les fichiers par empreinte et par taille (MD5 sur les stockages distants compatibles S3), plutôt que par date de modification et par taille. Associée à la vérification d’empreinte effectuée par défaut par rclone après le transfert, cette comparaison détecte les corruptions pendant le transfert lorsque le stockage distant conserve une empreinte exploitable.

La durée de conservation est gérée par une tâche de nettoyage distincte qui s’exécute hebdomadairement. La politique de conservation est : sauvegardes quotidiennes conservées pendant sept jours, sauvegardes hebdomadaires (sauvegarde du dimanche, renommée par une tâche hebdomadaire) conservées pendant quatre semaines, sauvegardes mensuelles (premier dimanche de chaque mois) conservées pendant six mois. Cela donne une fenêtre raisonnable pour détecter des pertes de données qui ne sont pas immédiatement évidentes – une ligne corrompue introduite il y a trois semaines peut encore être récupérée à partir d’une sauvegarde hebdomadaire.

Le nettoyage des sauvegardes anciennes utilise rclone delete avec un filtre sur la date de modification (--min-age) plutôt que de se fier uniquement aux noms de fichiers, ce qui est plus fiable lorsque les conventions de nommage des fichiers ne sont pas parfaitement cohérentes :

rclone delete "backup-remote:tva-backups/postgres/"   --min-age 7d   --include "*_daily_*"   --dry-run

L’option --dry-run est utilisée lors du test de la tâche de nettoyage. Retirez cette option uniquement après avoir confirmé que les motifs de filtrage correspondent exactement à ce qui doit être supprimé. Le motif de --include suppose que les sauvegardes quotidiennes portent le marqueur _daily_ dans leur nom ; le script de sauvegarde ci-dessus nomme les fichiers DB_TIMESTAMP.sql.gz. Il faut donc d’abord adapter le motif ou les noms de fichiers.

Tests de restauration

Une sauvegarde qui n’a jamais été testée est une hypothèse. La seule façon de savoir qu’une sauvegarde est restaurable est de la restaurer. Nous exécutons un test de restauration mensuel pour chaque base de données, en utilisant un conteneur Docker temporaire isolé de la pile de production :

#!/usr/bin/env bash
set -euo pipefail

BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"

# Démarrer un conteneur PostgreSQL temporaire
docker run -d   --name "${TEST_CONTAINER}"   -e POSTGRES_PASSWORD=testpass   -e POSTGRES_DB=testdb   postgres:15-alpine

# Supprimer le conteneur à la sortie, même si une étape échoue
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT

# Attendre que PostgreSQL soit prêt
for _ in $(seq 1 30); do
  docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
  sleep 1
done

# pg_dump omet les rôles : les restaurer avant tout depuis un fichier pg_dumpall --roles-only (2e argument facultatif)
if [ -n "${ROLES_FILE}" ]; then
  docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi

# Restaurer la sauvegarde
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -v ON_ERROR_STOP=1

# Vérifier que les comptages de lignes correspondent aux attentes
docker exec "${TEST_CONTAINER}"   psql -U postgres -d testdb   -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

La vérification du nombre de lignes n’est pas exhaustive – elle vérifie que les tables principales ont un nombre estimé de lignes plausible (n_live_tup est une estimation statistique, pas un décompte exact), pas que chaque ligne est correcte. Un test plus approfondi exécuterait les propres contrôles d’état de l’application sur la base de données restaurée, mais la vérification du nombre de lignes détecte les modes de défaillance les plus courants : une sauvegarde tronquée, une restauration qui a échoué et produit une base de données vide, ou une incompatibilité de version qui a causé une perte silencieuse de données. La restauration s’exécute avec ON_ERROR_STOP=1 : la première instruction en échec fait échouer le test, et le trap supprime le conteneur dans tous les cas. pg_dump n’inclut pas les rôles : si le dump fait référence à d’autres rôles que postgres, passez en deuxième argument la sortie de pg_dumpall --roles-only du serveur source (une erreur pour le rôle postgres, qui existe déjà, est normale à cette étape). L’image de test doit correspondre à la version majeure de PostgreSQL de la source. Le dump Supabase fait en outre référence à des schémas et extensions propres à Supabase, absents de l’image postgres standard ; il doit donc être restauré pour le test dans une instance Supabase, selon la procédure de restauration de Supabase.

Comment se déroule une reprise réelle

L’utilité de la stratégie de sauvegarde dépend de l’efficacité de la procédure de reprise qui l’exploite. Le guide opérationnel de reprise – rédigé sous forme de document Markdown dans le dépôt de configuration du serveur – documente la séquence exacte d’étapes pour restaurer chaque service sur un nouveau VPS.

La séquence pour une défaillance complète du serveur est : provisionner un nouveau VPS, installer Docker et la pile applicative depuis le dépôt de configuration, télécharger les fichiers de sauvegarde les plus récents depuis le stockage objet, restaurer chaque base de données dans l’ordre des dépendances (Supabase en premier, puis CRM, puis les autres), restaurer le stockage de fichiers, mettre à jour le DNS et vérifier chaque service. Le temps estimé pour une reprise complète est inférieur à deux heures si toutes les sauvegardes sont à jour et si le guide opérationnel est suivi sans improvisation.

L’expression clé dans cette estimation est « suivi sans improvisation ». Le guide opérationnel devrait être suffisamment précis pour qu’une personne n’ayant jamais touché au système puisse l’exécuter avec succès. Les commandes devraient pouvoir être copiées et collées, plutôt que décrites en prose. Chaque étape devrait comporter un contrôle. L’ambiguïté dans un guide opérationnel de reprise est un risque qui s’amplifie sous le stress d’un incident réel.

Nous testons le guide opérationnel annuellement en effectuant une reprise complète dans un environnement de préproduction. Le test met de manière fiable en évidence au moins une étape qui a changé depuis la dernière rédaction du guide opérationnel – une version d’image Docker qui a changé, une clé de configuration renommée, une variable d’environnement ajoutée. Détecter ces problèmes lors d’un test planifié est de loin préférable à les découvrir lors d’une véritable reprise.

Analyses connexes

Pour aller plus loin