Aller au contenu principal

Backups

Architecture​

Schéma agrandi
100 %

Chiffrement : AES-256 côté client (restic pour penny + Vaultwarden). PBS chiffré au niveau datastore (AES-256-GCM).

Règle 3-2-1 :

  • 3 copies : live (SSD/eMMC) + PBS local (penny SSD via NFS) + Cloudflare R2 EU (restic chiffré)
  • 2 supports : SSD/eMMC + cloud
  • 1 copie hors-site : Cloudflare R2 EU

Proxmox Backup Server (PBS)​

Infrastructure​

ElementDétail
LXC103 "pbs" sur lancelot, unprivileged, Debian 13, IP 192.168.1.33
Datastore "main"/mnt/datastore (bind-mount depuis lancelot /mnt/pbs-nfs)
Stockage reelNFS sur penny, export /mnt/ssd/pbs-datastore (65536 chunk dirs)
NFS exportall_squash anonuid=100034 (UID mappe du LXC unprivileged)
UIbackup.home.gabin-simond.fr via Traefik → 192.168.1.33:8007
AuthAuthelia OIDC realm "authelia" + comptes locaux break-glass

Comptes PBS​

Voir comptes.md pour la convention complète.

CompteRôleUsage
root@pamSuperuserBreak-glass uniquement
gabins@autheliaAdmin sur /Login quotidien UI via OIDC 2FA
gabins@pbsAdmin sur /Break-glass si Authelia down
svc-pve-backup@pbs!pveDatastorePowerUser sur /datastore/mainJobs backup PVE Datacenter

Backup des LXC via PBS​

Les deux nodes PVE (galahad + lancelot) envoient leurs LXC directement au PBS via l'API native. Plus besoin de vzdump-daily.sh + rsync + pull vers penny.

LXCContenuNode PVECriticite
100 (dns-failover)AdGuard secondairegalahadMoyenne
101 (logs)Loki + GrafanalancelotFaible
102 (vault)VaultwardengalahadCritique
103 (pbs)PBS lui-mêmelancelotMoyenne (reconstructible)

Rétention côté PBS​

Relevé le 2026-09-25 (proxmox-backup-manager prune-job list). Elle n'était documentée nulle part, alors qu'elle décide combien de temps on peut remonter :

JobDatastorePlanificationRétention
p-dailymain03:307 quotidiens, 4 hebdomadaires, 3 mensuels

À comparer au 7d/4w/6m des dépôts restic : PBS garde trois mois, restic six. Pour une restauration au-delà de trois mois, c'est donc restic qu'il faut viser, pas PBS.

Un avertissement du CLI de PBS a disparu le 2026-09-25

Jusqu'à ce jour, toute commande proxmox-backup-manager préfixait sa sortie de user config - ignore invalid acl token 'pulse@pve!pulse' — une entrée d'ACL pointant un jeton inexistant, laissée par le raccordement de Pulse. Sans effet sur les sauvegardes, mais elle polluait toute sortie qu'on voudrait parser : un script lisant la première ligne lisait cet avertissement.

Le compte pulse@pve a été supprimé, la sortie est propre. Noté ici parce que d'anciennes transcriptions et captures montrent encore cette ligne — ce n'est plus un symptôme à chercher.

vzdump hook temporaire

Les LXC sont backupes en mode stop (pas snapshot) car les rootfs sont sur stockage local (dir, pas ZFS). Le hook /usr/local/bin/vzdump-permfix-hook.sh corrige un bug de permissions sur pct.conf pour les LXC unprivileged. A supprimer quand les rootfs seront migres sur ZFS (mode snapshot natif).

vzdump-permfix-hook.sh​

Hook vzdump enregistré dans /etc/vzdump.conf sur galahad et lancelot :

# /etc/vzdump.conf
tmpdir: /tmp
script: /usr/local/bin/vzdump-permfix-hook.sh

Le hook lance un watcher en arriere-plan a backup-start qui surveillé l'apparition de pct.conf dans le tmpdir et corrige ses permissions (chmod a+rX dirs, chmod a+r fichiers) pour que lxc-usernsexec (UID 100000) puisse les lire.


Restic-direct vers Cloudflare R2 (4 chaines parallel)​

Chaque LXC critique a un backup restic indépendant direct vers R2, complementaire de PBS. Même si PBS (LXC 103) tombe, ces chaines continuent — path de survie en cas de lancelot down prolonge (cas 2026-04-19 : lancelot offline, PBS KO, mais ces backups ont tourne). Migration depuis Backblaze B2 effectuee 2026-05-11 (r2-migration.md, b2-cap-exceeded.md).

Vue d'ensemble​

Repo R2SourceScriptFréquenceRetentionSeuil freshness monitor
resticpenny configs+volumes+pbs-datastore/root/homelab_backup.shdaily 03:007d/4w/6m30h
restic-vaultLXC 102 vaultwarden/usr/local/bin/vault-backup.shhourly7d/4w/6m3h
restic-dnsfailoverLXC 100 AdGuard secondaire/usr/local/bin/dnsfailover-backup.shdaily 02:307d/4w/6m30h
restic-logsLXC 101 Grafana+Loki+Prometheus/usr/local/bin/logs-backup.shdaily 02:457d/4w/6m30h

Master password restic partagé (RESTIC_PASSWORD dans /root/.restic-env sur chaque host/LXC). R2 bucket unique homelab-backups, sous-path distinct par repo.

Vault — hourly pattern​

ElementDétail
Script/usr/local/bin/vault-backup.sh dans LXC 102
CronHoraire (snapshot SQLite atomic via .backup API)
Méthode.backup vers /var/backups/vault/db.sqlite3 (zero downtime) + /opt/vaultwarden/ complet
Notificationntfy (low OK, high FAIL)

DNS-failover / Logs — daily pattern​

Même script structure, quotidien (nouveau 2026-04-19 pour fermer le SPOF "pas de backup si lancelot down" sur LXC 100 et 101). Scripts versionnes dans homelab-config/system/lxc-scripts/.

Intégrité : restic-check-monthly.sh multi-repo​

Systemd timer penny 1er de chaque mois 04:20 (restic-check-monthly.timer) : restic check (structure) + restic check --read-data-subset=10% (bit rot détection 10% random packs) sur les 4 repos. Sur 10 mois, couvre ~100% de chaque repo.

Alerte ntfy haute si UN repo échoué (les autres continuent). Script : scripts/restic-check-monthly.sh.

Restauration : restic-drill-monthly.sh (restic + pbs-datastore)​

Systemd timer penny 1er de chaque mois 05:20 (restic-drill-monthly.timer) : restore réel d'un fichier du dernier snapshot de chaque repo restic (lisibilité vérifiée), puis pbs-datastore : rclone check --one-way R2→local (hash, ~11 min dominées par le hash local des ~10 GiB) + restore témoin d'un fichier avec comparaison md5. Détecte une corruption qui passerait check, et couvre le datastore PBS qui est hors restic (sync rclone direct).

Utilisabilité : pbs-snapshot-integrity.sh​

Systemd timer penny quotidien à 04:05 (pbs-snapshot-integrity.timer), depuis le 2026-09-24. Il répond à une question que rien d'autre ne posait : une sauvegarde qui existe est-elle utilisable ?

Trois contrôles sur le datastore monté localement (/mnt/ssd/pbs-datastore) :

ContrôleCe que ça révèle
*.tmp_didx résiduelÉcriture interrompue — snapshot inutilisable
Pas de index.json.blobPas de manifeste : PBS refusera la restauration
Un groupe ct/<id> sans aucun snapshot validePlus rien à restaurer pour cet invité

Le troisième est le plus grave et le moins visible : les deux premiers laissent au moins les sauvegardes précédentes debout.

Deux contrôles verts sur une sauvegarde irrécupérable

Le 2026-09-24, la panique noyau de lancelot à 02:02 a interrompu une sauvegarde en cours. Le répertoire du snapshot est resté en place, daté du jour, contenant deux fichiers .tmp_didx à zéro octet et aucun manifeste.

Pendant ce temps le moniteur annonçait « PBS SYNC FRESHNESS: OK » et « PBS HEALTH: OK ». Les deux disaient vrai dans leur périmètre :

  • la fraîcheur voit un répertoire daté d'aujourd'hui et se déclare satisfaite ;
  • la santé voit un HTTP 200 et se déclare satisfaite.

Aucune des deux ne regarde le contenu. Une sauvegarde qui démarre et échoue passe les deux contrôles — et c'est le pire cas possible, parce qu'on ne découvre le trou qu'au moment de restaurer.

La sonde refuse d'être aveugle : si le datastore est absent ou illisible, elle alerte au lieu de rendre « aucun problème ». C'est explicitement la leçon de la sonde SMART muette.

Pourquoi des timers systemd et plus des crons (2026-06-11)

Cron ne rattrape jamais un run manqué : le drill du 2026-06-01 (05:00) est tombé pendant le downtime penny du 31/05-03/06 et a sauté silencieusement — première fenêtre depuis le fix du parsing R2, donc la chaîne de restore est restée non validée tout le mois. Les 4 jobs hebdo/mensuels (restic-check-monthly, restic-drill-monthly, digest-drift-check, lynis-weekly) sont migrés vers des timers Persistent=true : un run manqué s'exécute au boot suivant. Les jobs quotidiens restent en cron (un run manqué se rattrape le lendemain). Units : homelab-config/system/systemd/*.{service,timer} — penser à Environment=HOME=/root (sans lui restic tourne sans cache).

Freshness monitor​

homelab_monitor.sh / check_restic_repos_freshness query R2 directement toutes les heures (cache 1h par repo) pour détecter si un cron silencieusement casse. Seuil par repo (vault 3h car hourly, autres 30h car daily).


penny — restic vers R2​

Ce qui est sauvegarde​

Volumes Docker (stages puis backup) :

DonnéeVolumeRépertoire de stagingCriticite
CrowdSec (enrôlement des bouncers, cache des décisions)docker_crowdsec-datacrowdsecMoyenne
Certificats Let's Encrypt (acme.json)config_traefik-certstraefik-certsHaute
AdGuard (baux DHCP, cache des filtres)config_adguard-dataadguard-runtimeFaible

La liste fait foi dans VOLUMES= de scripts/homelab_backup.sh. Le répertoire de staging porte l'étiquette, pas le nom du volume. Corrigé le 2026-11-04 : ce tableau listait encore deux volumes sortis de la sauvegarde depuis des mois.

Configs avec secrets :

DonnéeCheminCriticite
Authelia (DB SQLite + clé OIDC + config + secrets)/mnt/ssd/config/authelia/Critique
AdGuard (config avec rewrites)/mnt/ssd/config/adguard/adguard-prod-1/Haute
Traefik (config + dynamic routes)/mnt/ssd/config/traefik/Haute
Homepage (dashboard config)/mnt/ssd/config/homepage/Faible
Scripts système/mnt/ssd/config/scripts/Moyenne
Boot config (cmdline, config.txt)/mnt/ssd/config/boot/Haute
System config (fstab, sysctl)/mnt/ssd/config/system/Haute

Via Git (a chaque modification)​

DonnéeRepoVisibilite
Configs applicatives (Traefik, AdGuard, Homepage, etc.)homelab-configPrive
Config système (boot, fstab, udev, sysctl, crontab)homelab-configPrive
Scripts (monitor, backup, vzdump, proxmox)homelab-configPrive
Templates Authelia (.example, sans secrets)homelab-configPrive
Documentationhomelab-docPublic

Script homelab_backup.sh​

Exécution : cron quotidien a 3h (0 3 * * *)

Fonctionnement :

  1. Vérification preflight (.restic-env présent, restic installe)
  2. Stage chaque volume Docker vers /mnt/ssd/.restic-staging/<label>/
  3. restic backup : staging + configs → R2 (chiffré AES-256)
  4. Nettoyage du staging
  5. restic forget : retention 7 daily / 4 weekly / 6 monthly + prune
  6. Notification ntfy (succès ou échec avec durée)

Vérification d'intégrité : restic-check-monthly.sh (1er du mois, 4h)

  • Vérification structure (indexes, packs)
  • Vérification 10% données aléatoires (détection bit rot)
  • Alerte ntfy en cas d'échec

Le filtre qui jetait les fichiers (2026-09-06)​

Pendant quatre mois, ce script a construit sa liste de chemins ainsi :

for p in "${CONFIG_PATHS[@]}"; do
[ -d "$p" ] && BACKUP_PATHS+=("$p")
done

-d ne repond vrai que pour un repertoire. Toute entree de type FICHIER etait donc ecartee sans un mot, et la sauvegarde se declarait « OK ». Deux entrees en sont mortes :

Entree declareeTypeJetee depuisCe qu'elle porte
/boot/firmware/dietpi.txtfichier2026-05-11la configuration globale DietPi
/mnt/ssd/config/iac/terraform/terraform.tfstatefichier2026-09-04l'etat OpenTofu des dix LXC du cluster

La seconde est la plus grave, et elle vient d'un changement bien intentionne : le 2026-09-04, l'entree iac/ (repertoire) a ete remplacee par le seul fichier d'etat, pour cesser d'expedier chaque nuit 26 Mo de binaire de provider retelechargeable. Le raisonnement etait juste ; le filtre l'a annule.

Mesure contre le depot R2, faite avant de conclure quoi que ce soit :

Snapshot/mnt/ssd/config/iacfichier d'etatdietpi.txt
2026-09-04present—absent
2026-09-05absentabsentabsent
2026-09-06absentabsentabsent

Deux nuits sans l'etat OpenTofu, alors que le script ecrit lui-meme « Seul ce backup le couvre donc » — l'etat est gitignore. Et aucun dietpi.txt dans aucun snapshot, jamais.

Ce qui a ete corrige, et pourquoi ca ne suffisait pas d'accepter les fichiers​

Le filtre teste desormais -e, qui repond vrai pour un fichier comme pour un repertoire. Mais accepter les fichiers ne repare que la moitie du defaut. L'autre moitie est que le && approuvait l'absence de son objet :

La question a poser d'un controle n'est pas « detecte-t-il le probleme ? » mais « que rend-il si sa cible disparait ? ». S'il rend vert, ce n'est pas un controle.

CONFIG_PATHS est une declaration d'intention. Un chemin qui n'y correspond plus a rien est une anomalie, pas un « rien a faire ». Le script journalise donc les manquants, marque ERRORS — ce qui transforme le resume final en « Backup partiel » au lieu de « Backup OK » — et notifie. Il n'interrompt pas : sauvegarder ce qui existe et crier vaut mieux que ne rien sauvegarder parce qu'une entree a vieilli.

Au passage, l'entree dietpi.txt designait le mauvais fichier. /boot et /boot/firmware en portent chacun un, et ce ne sont pas des liens : /boot/dietpi.txt est le vivant (222 references dans /boot/dietpi/), /boot/firmware/dietpi.txt est la copie de l'image de mars 2025 (aucune reference), qui porte encore AUTO_SETUP_GLOBAL_PASSWORD=dietpi en clair. CONFIG_PATHS designe desormais le premier.

Le test, parce que c'est son absence qui a laisse vivre la regression​

scripts/tests/backup-paths-selection.test.sh — 19 assertions, invoque par la CI. Il extrait la fonction et le bloc appelant du fichier de production, au lieu de les retaper.

Deux cas portent l'essentiel :

  • le cas 4 exige qu'un chemin declare et introuvable ressorte en MANQUANT. Le taire serait exactement la panne qu'on repare ;
  • le cas 7 est le test du test : il rejoue l'ancienne implementation [ -d ] et exige qu'elle echoue. Un test qui passerait aussi bien avec le bug ne protege rien.

Le cas 8 relit CONFIG_PATHS dans le fichier de production — jamais une copie.

Ce que la CI garantit vraiment sur CONFIG_PATHS​

Cette page a affirme, du 2026-09-04 au 2026-09-06, qu'« ajouter demain une entree qui n'existe pas casse la CI ». C'etait faux, et la mecanique du faux merite d'etre lue, parce que c'est celle que ce depot passe son temps a corriger : le cas 8 etait garde par [ -d /mnt/ssd/config ] && [ -r /root/.restic-env ], « il ne tourne que sur penny ». Sur le runner Forgejo, aucun des deux n'existe. Le cas 8 y etait donc saute en silence, la suite rendait 15 assertions au lieu de 18, et sortait 0. Un temoin qui mesure ce qui est disponible au moment du controle, et non le travail accompli, approuve son propre effacement.

Le cas 8 ne se saute plus jamais. Il distingue desormais deux choses, au lieu de promettre la seconde en ne faisant que la premiere :

Ce qui est declareCe qui le verifieOu
les 7 entrees situees sous le depot et versionnees (authelia, adguard/adguard-prod-1, traefik, homepage, scripts, boot, system)leur existence est controlee pour de vrai, contre la racine du checkout d'ou le test tourneCI et penny, identiquement
les 6 autres (iac/terraform/terraform.tfstate — gitignore —, /mnt/ssd/forgejo, /mnt/ssd/outline, /root/.config/sops/age, /etc/iptables, /boot/dietpi.txt)aucun checkout ne peut dire si elles existent sur penny. Le test refuse de le pretendre : il exige seulement qu'elles soient classees, et leur existence reelle est jugee chaque nuit par le script lui-meme (MANQUANT journalise, notifie, ERRORS marque)penny, au runtime

L'affirmation exacte est donc : toute entree ajoutee a CONFIG_PATHS casse la CI tant que personne n'est venu la classer dans le test — et si elle est sous le depot, son absence la casse aussi. Contre-epreuve du 2026-09-06 : une entree /mnt/ssd/config/ce-chemin-nexiste-pas ajoutee a la declaration fait tomber le cas 8a sur le runner Forgejo, rc=1. Et le compte d'assertions y est desormais le meme qu'en local : 19 = 19.

Contre-epreuve du filtre, meme jour : le [ -d ] reintroduit dans une copie du script fait tomber 9 des 19 assertions — les cas 2, 2b, 3, 4b, 5 et les quatre du cablage (cas 9). Le cas 8 n'y participe plus, et c'est assume : les sept entrees qu'il verifie sont toutes des repertoires, que [ -d ] accepte. C'est le cas 2b, qui simule le tfstate, qui porte cette part-la.

Destinations​

DestinationCheminRetentionChiffrementCout
Cloudflare R2 EU (restic penny)homelab-backups/restic7d / 4w / 6mAES-256 client-sideGratuit (<10 Go)
Cloudflare R2 EU (restic vault)homelab-backups/restic-vault7d / 4w / 6mAES-256 client-sideGratuit
PBS datastore "main" (NFS penny)/mnt/ssd/pbs-datastoreConfigurée dans PBSAES-256-GCMGratuit

Ce qui n'est PAS sauvegarde (reconstructible)​

DonnéeRaison
Images Dockerdocker compose pull
Cache Docker (overlay2)Reconstruit automatiquement
Certificats TLS (Traefik)Regeneres par Let's Encrypt
LogsEphemeres, pas critiques
Tailscale stateRe-auth suffit (tailscale up)
Proxmox configReinstallation via scripts (proxmox-post-install.sh)
PBS LXC 103Reconstructible (config + datastore sur NFS penny)

Restauration​

Restaurer un LXC depuis PBS​

# Via l'UI PBS (backup.home.gabin-simond.fr) :
# 1. Selectionner le snapshot dans le datastore "main"
# 2. Bouton "Restore" → choisir le node PVE cible
#
# Via CLI PVE :
pvesh create /nodes/<node>/lxc -archive <PBS-backup-ID> -storage local

Restaurer Vaultwarden depuis restic R2​

# Depuis LXC 102 (ou un nouveau LXC)
set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
restic restore latest --target /tmp/restore --tag vault
cp -a /tmp/restore/opt/vaultwarden/. /opt/vaultwarden/
# Restaurer le snapshot SQLite propre
cp /tmp/restore/var/backups/vault/db.sqlite3 /opt/vaultwarden/data/db.sqlite3
rm -rf /tmp/restore
systemctl restart vaultwarden

Restaurer un volume Docker (penny)​

set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
# Exemple : CrowdSec (volume docker_crowdsec-data, etiquette de staging crowdsec)
restic restore latest --target /tmp/restore --include "/mnt/ssd/.restic-staging/crowdsec"

docker compose stop crowdsec
docker run --rm \
-v docker_crowdsec-data:/data \
-v /tmp/restore/mnt/ssd/.restic-staging/crowdsec:/source:ro \
alpine sh -c "rm -rf /data/* && cp -a /source/. /data/"
docker compose up -d crowdsec
rm -rf /tmp/restore

Restaurer une config (penny)​

set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
restic restore latest --target /tmp/restore --include "/mnt/ssd/config/authelia"

docker compose stop authelia
cp -a /tmp/restore/mnt/ssd/config/authelia/. /mnt/ssd/config/authelia/
docker compose up -d authelia
rm -rf /tmp/restore

Restauration complète (nouveau RPi)​

Voir break-glass.md pour la procédure pas-a-pas.

  1. Installer DietPi
  2. Cloner homelab-config depuis GitHub
  3. Suivre le README (copier boot, udev, fstab, network, docker)
  4. Restaurer .restic-env depuis la clé USB chiffrée
  5. restic restore latest depuis R2
  6. Restaurer les volumes et configs
  7. Regenerer les secrets Authelia si nécessaire (voir README)
  8. docker compose up -d

Credentials restic​

# /root/.restic-env (chmod 600, sealed dans system/secrets/restic-env.enc)
RESTIC_PASSWORD=<mot-de-passe-chiffrement>
RESTIC_REPOSITORY=s3:https://<account-id>.eu.r2.cloudflarestorage.com/homelab-backups/restic
AWS_ACCESS_KEY_ID=<r2-access-key-id>
AWS_SECRET_ACCESS_KEY=<r2-secret-access-key>
R2_ACCOUNT_ID=<account-id>
R2_BUCKET=homelab-backups
R2_ENDPOINT=https://<account-id>.eu.r2.cloudflarestorage.com
Ce fichier est scellé via sops + age + 2 YubiKeys (DR)

Source de vérité : system/secrets/restic-env.enc. Live runtime : /root/.restic-env (chmod 600, déchiffré via age key locale OU YubiKey). Perte de tous les age keys + plaintext = perte des backups (RESTIC_PASSWORD est nécessaire pour décrypter les snapshots restic même si tu re-créais les keys API R2).

Bucket homelab-backups sur Cloudflare R2 EU jurisdiction. API token Cloudflare Object Read & Write scoped uniquement à ce bucket.


Voir aussi​