Salle des machines
Ce que fait vraiment tourner cv.ndv3.fr, ndv3.fr, lr9.ndv3.fr, evenyx.ndv3.fr et le reste : une Freebox, un NAS, neuf conteneurs Docker répartis en plusieurs stacks, et un reverse proxy qui route par nom de domaine — y compris vers une machine qui n'est pas le NAS. Plus deux bots Discord qui ne s'exposent jamais. Cliquez un bloc pour le détail.
Schéma
Clique un bloc du schéma pour afficher son détail.
Journal de bord
Le dashboard NAS lit un status.json généré toutes les minutes par un script cron, monté dans le conteneur en volume Docker. Premier test : 403 Forbidden, alors que ls -la côté NAS affichait rwxrwsrwx+ sur le dossier — permissions larges en apparence.
La cause : le NAS gère l'accès de ses dossiers partagés via des ACL (le + après les droits dans ls -la le signale). Un bind mount Docker ne lit que les bits POSIX classiques, pas les ACL — donc le conteneur voyait les vrais droits sous-jacents, bien plus restrictifs (rwx------), et refusait l'accès à l'utilisateur nginx du conteneur.
Résolu en forçant explicitement chmod 755 sur le dossier et 644 sur le fichier, sans compter sur l'ACL affiché. Le genre de piège qui n'existe que parce qu'on est sur un NAS et pas un serveur Linux classique.
Les bots Discord tournent en utilisateur non-root (USER node dans le Dockerfile) et doivent écrire leur état — avertissements de modération, dernier live annoncé, jetons d'API rafraîchis — dans /app/data. Premier réflexe : un bind mount vers ./data sur le partage du NAS. Résultat : refus d'écriture au démarrage, alors que les droits affichés semblaient permissifs.
Même racine que l'INC-01 : les ACL du NAS ne traversent pas le bind mount, et l'utilisateur node du conteneur n'a aucune correspondance côté partage — donc aucun droit réel.
Cette fois le chmod ne suffisait plus, parce que le dossier vit sur un partage entièrement géré par le NAS. Résolu avec un volume Docker nommé (discord-bot-ndv3-data), stocké et administré par le démon Docker lui-même, hors du partage et donc hors des ACL. Les données survivent aux redémarrages comme aux reconstructions d'image, et la sauvegarde se fait avec un conteneur jetable : docker run --rm -v discord-bot-ndv3-data:/data -v $(pwd):/backup alpine tar -cf /backup/bot-data.tar /data.
La leçon : sur un NAS, un bind mount vers un partage est un piège pour tout conteneur non-root. Le volume nommé n'est pas un contournement, c'est le bon outil.
docker compose introuvable
Le moteur Docker tourne, docker ps répond — mais docker compose et docker buildx renvoient command not found. Ce sont des plugins CLI, des binaires séparés attendus dans ~/.docker/cli-plugins, et l'interface du NAS n'installe que le client de base.
Contournement pendant la résolution : parler directement au démon via son socket Unix, sans passer par le CLI — curl --unix-socket /var/run/docker.sock pour déclencher la construction de l'image, puis un docker run --env-file .env -v discord-bot-ndv3-data:/app/data … pour démarrer le conteneur. Moins confortable qu'un compose up -d, mais strictement équivalent : Compose n'est qu'une surcouche qui traduit un YAML en appels à cette même API.
Deuxième point de friction sur la même chaîne : le compte utilisé pour déployer n'appartenait pas au groupe docker, donc pas d'accès au socket. Réglé par un usermod -aG docker ciblé plutôt qu'en faisant passer chaque commande par sudo — le principe du moindre privilège appliqué à la bonne granularité.
Le CV s'anime à l'ouverture : chaque bloc part à opacity:0 et remonte via une animation CSS. À l'impression, une règle animation:none désactivait proprement ces animations… et c'était exactement le problème. Sans animation pour l'exécuter, l'état final n'est jamais atteint : le PDF sortait avec le nom, la photo et le sous-titre invisibles, figés à opacity:0.
Deuxième couche : le conteneur principal porte un min-height:100vh. Masquer ses enfants un par un ne l'empêchait pas de réclamer sa hauteur — d'où des pages noires en fin de document, et l'écran de chargement qui se réimprimait sur chacune.
Corrigé en arrêtant de rafistoler la mise en page écran. Le document imprimé est désormais un bloc HTML dédié, masqué à l'écran et révélé uniquement en @media print, avec sa propre grille claire, ses règles break-inside:avoid pour ne jamais couper une expérience en deux, et un QR code vers la version en ligne. Le conteneur animé, lui, est masqué en entier plutôt que sélecteur par sélecteur. La photo est partagée entre les deux vues en recopiant son src au chargement, pour ne pas embarquer deux fois la même image en base64.
Vérifié en rendu réel plutôt qu'à l'œil : Chrome en mode --headless --print-to-pdf à chaque itération, jusqu'à ce que les deux pages sortent correctes.
Pile technique
Neuf conteneurs, plusieurs stacks, volumes nommés pour les données.
Reverse proxy, routage par domaine, TLS. Six hôtes déclarés.
Certificats TLS, renouvellement automatique.
Zone du domaine, un enregistrement A par sous-domaine.
Sites statiques CV, accueil, LR9 et EVENYX, config custom par service.
Analytics auto-hébergées, sans cookies.
Deux bots Discord, intégrations Twitch, YouTube et Instagram.
EVENYX : billetterie, contrôle d'accès, boutiques et planning d'événement.
Aucun appel à Google Fonts : rien ne fuite vers un tiers au chargement.