ADR 0022 – Déploiement : compose de prod, Caddy, OIDC par l'environnement, copie hors site restic
Accepté (2026-10-15, mode autonome)
ADR 0022 – Déploiement : compose de prod, Caddy, OIDC par l’environnement, copie hors site restic
Statut : Accepté (2026-10-15, mode autonome)
Décisions prises en mode autonome. Aurélien a demandé, le 2026-10-15, de choisir les options les plus simples, sûres et réversibles. Le serveur, le domaine et le compte Authentik n’existent pas encore. Cette tranche prépare donc tout ce qui n’en dépend pas, puis vérifie le résultat en local avec des valeurs factices. La mise en ligne réelle reste à faire.
Contexte
Avant cette tranche, il existait :
- l’image unique (
Dockerfile) : front Angular embarqué dans le jar ; - un
docker-compose.ymlde développement, où la base publie son port ; - les sauvegardes locales (ADR 0019).
Il manquait :
- un proxy HTTPS ;
- une configuration de production sans port exposé ;
- une configuration OIDC complète lue depuis l’environnement ;
- une copie hors site ;
- une procédure de mise en ligne.
Décision
Un fichier compose de prod autonome : docker-compose.prod.yml
Ce fichier contient les services db, app, caddy, backup et restore (ce dernier dans le profil restore). Il est séparé du compose de développement, pour qu’aucune commande de développement ne touche la production. Son projet compose s’appelle second-brain-prod. Ce nom ne doit pas être second-brain, celui que prend implicitement le compose de développement dans un clone du même nom : les volumes seraient alors partagés. Tout vient d’un fichier .env.prod, construit à partir du modèle .env.prod.example, qui ne contient aucun secret.
Les variables indispensables utilisent la syntaxe ${VAR:?message} : un oubli arrête docker compose avant le démarrage.
Seul Caddy publie des ports (80, 443 et 443/udp). La base et l’application ne sont joignables que sur le réseau interne.
L’application a un healthcheck sur /actuator/health/readiness. Comme l’image n’a pas curl, la requête passe par /dev/tcp en bash. Caddy ne démarre qu’une fois l’application saine.
Caddy (deploy/Caddyfile)
Le Caddyfile est paramétré par l’environnement :
SB_SITE_ADDRESS: avec un domaine, le certificat Let’s Encrypt est automatique ; avec:80, on fait un essai local en HTTP ;SB_ACME_EMAIL;SB_MAX_BODY, qui vaut60MiBpar défaut. C’est la même valeur quespring.servlet.multipart.max-request-size: 60MB, que Spring lit en Mio : avec60MB, Caddy compterait 60 000 000 octets et refuserait en 413 vide des requêtes que Spring accepte.
/api/export et /api/export/* passent par un reverse_proxy dédié : pas de compression, flush_interval -1 (aucun tampon), et pas de délai sur la réponse. Le ZIP est écrit en flux et peut durer (ADR 0018). Le reste est compressé en zstd ou gzip.
Les en-têtes ajoutés sont HSTS, nosniff et Referrer-Policy, et Server est retiré. La CSP stricte reste celle des fichiers servis (ADR 0012). Une CSP pour toute l’application est reportée : il faut d’abord inventorier les styles en ligne d’Angular Material.
Application derrière le proxy
SERVER_FORWARD_HEADERS_STRATEGY=native: la valveRemoteIpde Tomcat litX-Forwarded-*depuis le réseau interne Docker. Le schéma est alorshttps, l’URL de retour OIDC est juste et le cookie de session estSecure.- Le cookie de session est déclaré
SecureetSameSite=Lax.SB_COOKIE_SECURE=falseexiste seulement pour l’essai local en HTTP. - OIDC passe par des variables courtes (
SB_OIDC_ISSUER_URI,SB_OIDC_CLIENT_ID,SB_OIDC_CLIENT_SECRET,SB_OIDC_SCOPE). Le compose les traduit vers les propriétés Spring de l’enregistrementoidc(ADR 0004). Avecissuer-uri, l’application lit au démarrage le document de découverte d’Authentik. Si Authentik est injoignable, elle ne démarre pas : l’échec est visible tout de suite. TZ=Europe/Brussels(SB_TZ) s’applique à l’application, à la base et à Caddy ; le service de sauvegarde a son propreBACKUP_TZ. L’horloge de l’application reste en UTC (ADR 0016). En revanche,TZfixe le fuseau par défaut de la JVM (ZoneId.systemDefault()). C’est lui qui sert quand une requête ne précise pas de fuseau (valeurs par défaut dynamiques, brouillons de modèle) : il vaut donc Bruxelles, et c’est voulu.
Copie hors site chiffrée : restic, facultative
backup/bin/offsite.sh copie un instantané local, après une sauvegarde réussie, dans un dépôt restic : chemin local, S3, B2 ou serveur REST (rest-server). SFTP n’est pas pris en charge, car l’image de sauvegarde n’a pas de client ssh.
- Chiffrement côté client (AES-256) : la clé ne quitte pas le serveur. Elle doit aussi être conservée ailleurs, sans quoi la copie est illisible.
- Déduplication : seuls les contenus nouveaux sont envoyés, y compris d’un instantané à l’autre.
- Rotation distante :
forget --prune --group-by host,tagsen 7 jours, 8 semaines et 12 mois. Chaque copie a un chemin différent (/backups/<instantané>). Avec le regroupement par défaut (hôte et chemin), chaque copie serait seule dans son groupe et rien ne serait jamais supprimé. La copie est datée de l’heure de l’instantané (--time), pas de celle de l’envoi. - Verrou : la copie et le rapatriement prennent le verrou
/backups/.lockde la sauvegarde, de la rotation et du test de restauration. Un second lancement est refusé tant qu’un autre est en cours. - Coût : comme le chemin change, restic relit et rehache tout l’instantané à chaque copie ; seuls les blocs nouveaux sont envoyés. Le cache d’index est sur un volume (
restic-cache). Lepruneest quotidien. S’il devient lent sur un gros dépôt, on le passera en hebdomadaire.
La copie est désactivée si BACKUP_OFFSITE_REPOSITORY est vide. Son échec n’annule pas la sauvegarde locale. Il est enregistré dans status.json (offsite, avec la dernière ligne d’erreur de restic), et le planificateur retente la copie après BACKUP_RETRY_MINUTES. Le service backup devient non sain si aucune copie n’a jamais réussi, ou si la dernière réussite a plus de BACKUP_OFFSITE_MAX_AGE_HOURS heures (50 par défaut). Une coupure réseau d’une nuit ne suffit donc pas à le rendre non sain.
offsite.sh --fetch [id] rapatrie un instantané distant dans un dossier temporaire sous /backups, le passe à verify.sh, puis le renomme : rien d’incomplet n’apparaît comme instantané. restore.sh s’applique ensuite comme d’habitude. La clé est lue dans un fichier régulier non vide. Avec create_host_path: false, un fichier absent de l’hôte arrête compose au lieu d’être créé en dossier.
restic a été préféré à deux alternatives :
- rclone +
age: un script de chiffrement à écrire et à maintenir, sans déduplication ; pg_dumpchiffré par GPG : il ne couvre ni les fichiers niconfig/.
Icônes Material Symbols auto-hébergées
Le dernier appel à Google Fonts disparaît. Les icônes viennent de @fontsource/material-symbols-outlined, en graisse 400 seulement (environ 350 Ko, contre 4 Mo pour la police variable du paquet material-symbols), avec font-display: block pour qu’on ne voie pas la ligature en clair au chargement.
Essai local : deploy/smoke/smoke.sh
Le script construit les images et démarre le compose de prod. Il lui ajoute une surcouche (deploy/smoke/compose.smoke.yml, projet second-brain-smoke) qui apporte :
- un fournisseur OIDC factice local, un Caddy qui ne sert que le document de découverte ;
- Caddy sur
:18080; - un dépôt restic local.
Il vérifie ensuite :
- la santé via Caddy ;
- la redirection vers la connexion, puis vers le fournisseur, avec la bonne URL de retour ;
- la réponse 401 de l’API ;
- l’en-tête
nosniff; - la limite de 60 Mio chargée par Caddy (configuration adaptée), et le passage d’un corps de 1 Mo jusqu’à l’application. Un vrai refus en 413 ne peut pas être observé sans session : l’application répond 403 (CSRF) avant que le corps ne soit lu ;
- le fuseau horaire ;
- l’absence de port publié par la base ;
- une sauvegarde, sa copie hors site chiffrée et l’état sain des sauvegardes.
Pour finir, il supprime tout, volumes compris. Aucun serveur externe n’est contacté.
Reporté
- Mise en ligne réelle : le serveur, le DNS, l’application Authentik et le premier certificat. La liste de ce qu’il faut fournir est dans
docs/deploiement.md. - Image publiée dans un registre et mise à jour automatique : l’image est construite sur le serveur.
- Déconnexion propagée à Authentik (
end_session_endpoint) : aujourd’hui, la déconnexion ne ferme que la session de l’application. - CSP sur toute l’application, alerte active sur échec de sauvegarde (le
healthcheckdu service suffit pour l’instant), surveillance.
Conséquences
.env.prod,/secrets/et/backups-smoke/sont ignorés par Git..env.*etdeploy/sont exclus du contexte de construction Docker.- L’image de sauvegarde gagne
restic(paquet Alpine). - Toute nouvelle variable d’exploitation est déclarée dans
docker-compose.prod.ymlet documentée dans.env.prod.example.