Second Brain documentation

ADR 0022 – Déploiement : compose de prod, Caddy, OIDC par l'environnement, copie hors site restic

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

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.yml de 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 vaut 60MiB par défaut. C’est la même valeur que spring.servlet.multipart.max-request-size: 60MB, que Spring lit en Mio : avec 60MB, 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 valve RemoteIp de Tomcat lit X-Forwarded-* depuis le réseau interne Docker. Le schéma est alors https, l’URL de retour OIDC est juste et le cookie de session est Secure.
  • Le cookie de session est déclaré Secure et SameSite=Lax. SB_COOKIE_SECURE=false existe 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’enregistrement oidc (ADR 0004). Avec issuer-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 propre BACKUP_TZ. L’horloge de l’application reste en UTC (ADR 0016). En revanche, TZ fixe 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,tags en 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/.lock de 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). Le prune est 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_dump chiffré par GPG : il ne couvre ni les fichiers ni config/.

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 healthcheck du service suffit pour l’instant), surveillance.

Conséquences

  • .env.prod, /secrets/ et /backups-smoke/ sont ignorés par Git. .env.* et deploy/ 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.yml et documentée dans .env.prod.example.