Le déploiement restait bloqué sur ENCRYPTION_KEY. J'ai tenu trop longtemps la position « pas de valeur par défaut », en confondant deux choses : refuser une clé livrée avec l'image — ce qui reste juste, une clé publiée dans un dépôt ne protège rien — et exiger qu'un humain en fabrique une avant tout démarrage. Une variable d'environnement n'est d'ailleurs pas un bon coffre : elle s'affiche dans `docker inspect` et dans l'interface de gestion. Un fichier produit au démarrage, dans un volume distinct de la base et des documents, n'est pas moins protégé — et une sauvegarde de l'un n'emporte plus la clé de l'autre. Le point d'entrée la produit donc si elle manque, l'écrit en 0600, et l'affiche une fois dans les journaux avec ce qu'il faut en faire. Une clé fournie explicitement l'emporte toujours : un déploiement qui gère ses secrets ailleurs ne doit pas être contrarié. La pile démarre désormais sans aucune variable. Éprouvé sur le script lui-même : première exécution, clé de 32 octets produite et annoncée ; deuxième, reprise en silence depuis le fichier ; avec une clé fournie, le fichier reste intact. Corrigé au passage, révélé par un échec transitoire du test : une écriture disque impossible — volume plein, droits, montage absent — remontait en exception non traitée. L'écran restait muet, la pièce n'était pas déposée et rien ne le disait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
112 lines
4.7 KiB
YAML
112 lines
4.7 KiB
YAML
name: planflow
|
|
|
|
services:
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
|
# Valeur par défaut assumée : la base n'est jamais publiée et reste sur le
|
|
# réseau privé de la pile, où seule l'application l'atteint. Ce mot de
|
|
# passe ne protège donc pas d'Internet — il protège d'un autre conteneur
|
|
# du même réseau. Le changer reste recommandé.
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
|
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
|
# Transmis au script d'initialisation, qui crée le rôle de connexion de
|
|
# l'application — celui-ci ne doit surtout pas être le superutilisateur.
|
|
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
|
# Deterministic collation: ordering of employee names must not depend on
|
|
# the host locale, or exports differ between machines.
|
|
LANG: C.UTF-8
|
|
volumes:
|
|
- db-data:/var/lib/postgresql/data
|
|
# Crée le rôle applicatif à la première initialisation du volume.
|
|
- ./docker/init-app-role.sh:/docker-entrypoint-initdb.d/10-init-app-role.sh:ro
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
# Volontairement absente du réseau externe : elle n'a besoin que de
|
|
# l'application. L'y attacher exposerait la base à tout ce que le
|
|
# reverse-proxy héberge, et le mot de passe par défaut deviendrait alors un
|
|
# vrai problème.
|
|
networks:
|
|
- interne
|
|
# Jamais publiée : rien hors de la pile n'a besoin de la base, et un jeu de
|
|
# données RH ne doit pas être à une règle de pare-feu du monde entier.
|
|
expose:
|
|
- '5432'
|
|
|
|
app:
|
|
build:
|
|
context: .
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
NODE_ENV: production
|
|
# Le compte applicatif, **pas** le superutilisateur d'amorçage : un
|
|
# superutilisateur contourne la row-level security, y compris déclarée en
|
|
# FORCE, et l'isolation ne reposerait plus que sur la couche applicative.
|
|
DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:5432/${POSTGRES_DB:-planflow}
|
|
# Laissée vide, elle est **produite au premier démarrage** et conservée
|
|
# dans le volume `secrets`. La renseigner ici reste possible pour un
|
|
# déploiement qui gère ses secrets par ailleurs — mais une variable
|
|
# d'environnement s'affiche dans `docker inspect` et dans l'interface de
|
|
# gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier.
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
|
APP_URL: ${APP_URL:-http://localhost:9317}
|
|
# Chemin **dans le volume**, pas dans l'image : les pièces du dossier
|
|
# salarié écrites dans la couche du conteneur disparaîtraient au premier
|
|
# redéploiement, et une pièce d'identité perdue ne se reconstitue pas.
|
|
DOCUMENT_STORE: /data/documents
|
|
volumes:
|
|
- documents:/data
|
|
# Volume distinct de la base et des documents : sauvegarder l'un ne doit
|
|
# pas emporter la clé qui déchiffre l'autre.
|
|
- secrets:/secrets
|
|
networks:
|
|
# `interne` pour joindre la base, `nginx_default` pour être joignable par
|
|
# le reverse-proxy — qui atteint le conteneur sur son port 3000, sans
|
|
# passer par le port publié.
|
|
- interne
|
|
- nginx_default
|
|
ports:
|
|
# Port peu courant : le service est censé passer par le reverse-proxy, et
|
|
# un 3000 publié sur l'hôte se heurte à tout ce qui traîne.
|
|
- '${APP_PORT:-9317}:3000'
|
|
healthcheck:
|
|
test:
|
|
[
|
|
'CMD',
|
|
'node',
|
|
'-e',
|
|
"fetch('http://127.0.0.1:3000/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 30s
|
|
|
|
networks:
|
|
# Réseau privé de la pile : base et application, rien d'autre.
|
|
interne:
|
|
|
|
# Réseau du reverse-proxy, créé en dehors de cette pile. Le déclarer externe
|
|
# évite d'en fabriquer un second du même nom, sur lequel nginx ne verrait rien.
|
|
nginx_default:
|
|
external: true
|
|
|
|
volumes:
|
|
db-data:
|
|
# Clé de chiffrement produite au premier démarrage. À sauvegarder
|
|
# **séparément** du reste : réunis, le coffre et sa clé ne protègent plus rien.
|
|
secrets:
|
|
# Pièces du dossier salarié, chiffrées au repos avec ENCRYPTION_KEY. À
|
|
# sauvegarder avec la base : l'une sans l'autre restitue un dossier amputé,
|
|
# et sans la clé le volume est illisible.
|
|
documents:
|