Backups
Architecture
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
| Element | Détail |
|---|---|
| LXC | 103 "pbs" sur lancelot, unprivileged, Debian 13, IP 192.168.1.33 |
| Datastore "main" | /mnt/datastore (bind-mount depuis lancelot /mnt/pbs-nfs) |
| Stockage reel | NFS sur penny, export /mnt/ssd/pbs-datastore (65536 chunk dirs) |
| NFS export | all_squash anonuid=100034 (UID mappe du LXC unprivileged) |
| UI | backup.home.gabin-simond.fr via Traefik → 192.168.1.33:8007 |
| Auth | Authelia OIDC realm "authelia" + comptes locaux break-glass |
Comptes PBS
Voir comptes.md pour la convention complète.
| Compte | Rôle | Usage |
|---|---|---|
root@pam | Superuser | Break-glass uniquement |
gabins@authelia | Admin sur / | Login quotidien UI via OIDC 2FA |
gabins@pbs | Admin sur / | Break-glass si Authelia down |
svc-pve-backup@pbs!pve | DatastorePowerUser sur /datastore/main | Jobs 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.
| LXC | Contenu | Node PVE | Criticite |
|---|---|---|---|
| 100 (dns-failover) | AdGuard secondaire | galahad | Moyenne |
| 101 (logs) | Loki + Grafana | lancelot | Faible |
| 102 (vault) | Vaultwarden | galahad | Critique |
| 103 (pbs) | PBS lui-même | lancelot | Moyenne (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 :
| Job | Datastore | Planification | Rétention |
|---|---|---|---|
p-daily | main | 03:30 | 7 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.
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.
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 R2 | Source | Script | Fréquence | Retention | Seuil freshness monitor |
|---|---|---|---|---|---|
restic | penny configs+volumes+pbs-datastore | /root/homelab_backup.sh | daily 03:00 | 7d/4w/6m | 30h |
restic-vault | LXC 102 vaultwarden | /usr/local/bin/vault-backup.sh | hourly | 7d/4w/6m | 3h |
restic-dnsfailover | LXC 100 AdGuard secondaire | /usr/local/bin/dnsfailover-backup.sh | daily 02:30 | 7d/4w/6m | 30h |
restic-logs | LXC 101 Grafana+Loki+Prometheus | /usr/local/bin/logs-backup.sh | daily 02:45 | 7d/4w/6m | 30h |
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
| Element | Détail |
|---|---|
| Script | /usr/local/bin/vault-backup.sh dans LXC 102 |
| Cron | Horaire (snapshot SQLite atomic via .backup API) |
| Méthode | .backup vers /var/backups/vault/db.sqlite3 (zero downtime) + /opt/vaultwarden/ complet |
| Notification | ntfy (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ôle | Ce que ça révèle |
|---|---|
*.tmp_didx résiduel | Écriture interrompue — snapshot inutilisable |
Pas de index.json.blob | Pas de manifeste : PBS refusera la restauration |
Un groupe ct/<id> sans aucun snapshot valide | Plus 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.
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.
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ée | Volume | Répertoire de staging | Criticite |
|---|---|---|---|
| CrowdSec (enrôlement des bouncers, cache des décisions) | docker_crowdsec-data | crowdsec | Moyenne |
Certificats Let's Encrypt (acme.json) | config_traefik-certs | traefik-certs | Haute |
| AdGuard (baux DHCP, cache des filtres) | config_adguard-data | adguard-runtime | Faible |
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ée | Chemin | Criticite |
|---|---|---|
| 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ée | Repo | Visibilite |
|---|---|---|
| Configs applicatives (Traefik, AdGuard, Homepage, etc.) | homelab-config | Prive |
| Config système (boot, fstab, udev, sysctl, crontab) | homelab-config | Prive |
| Scripts (monitor, backup, vzdump, proxmox) | homelab-config | Prive |
Templates Authelia (.example, sans secrets) | homelab-config | Prive |
| Documentation | homelab-doc | Public |
Script homelab_backup.sh
Exécution : cron quotidien a 3h (0 3 * * *)
Fonctionnement :
- Vérification preflight (
.restic-envprésent,resticinstalle) - Stage chaque volume Docker vers
/mnt/ssd/.restic-staging/<label>/ restic backup: staging + configs → R2 (chiffré AES-256)- Nettoyage du staging
restic forget: retention 7 daily / 4 weekly / 6 monthly + prune- 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 declaree | Type | Jetee depuis | Ce qu'elle porte |
|---|---|---|---|
/boot/firmware/dietpi.txt | fichier | 2026-05-11 | la configuration globale DietPi |
/mnt/ssd/config/iac/terraform/terraform.tfstate | fichier | 2026-09-04 | l'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/iac | fichier d'etat | dietpi.txt |
|---|---|---|---|
| 2026-09-04 | present | — | absent |
| 2026-09-05 | absent | absent | absent |
| 2026-09-06 | absent | absent | absent |
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 declare | Ce qui le verifie | Ou |
|---|---|---|
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 tourne | CI 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
| Destination | Chemin | Retention | Chiffrement | Cout |
|---|---|---|---|---|
| Cloudflare R2 EU (restic penny) | homelab-backups/restic | 7d / 4w / 6m | AES-256 client-side | Gratuit (<10 Go) |
| Cloudflare R2 EU (restic vault) | homelab-backups/restic-vault | 7d / 4w / 6m | AES-256 client-side | Gratuit |
| PBS datastore "main" (NFS penny) | /mnt/ssd/pbs-datastore | Configurée dans PBS | AES-256-GCM | Gratuit |
Ce qui n'est PAS sauvegarde (reconstructible)
| Donnée | Raison |
|---|---|
| Images Docker | docker compose pull |
| Cache Docker (overlay2) | Reconstruit automatiquement |
| Certificats TLS (Traefik) | Regeneres par Let's Encrypt |
| Logs | Ephemeres, pas critiques |
| Tailscale state | Re-auth suffit (tailscale up) |
| Proxmox config | Reinstallation via scripts (proxmox-post-install.sh) |
| PBS LXC 103 | Reconstructible (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.
- Installer DietPi
- Cloner
homelab-configdepuis GitHub - Suivre le README (copier boot, udev, fstab, network, docker)
- Restaurer
.restic-envdepuis la clé USB chiffrée restic restore latestdepuis R2- Restaurer les volumes et configs
- Regenerer les secrets Authelia si nécessaire (voir README)
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
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
- Comptes PBS — convention comptes et tokens
- Monitoring — checks backup freshness
- Break-glass — procédure reconstruction d'urgence