Files
planflow/docker-compose.yml
T
Claude 607de2a1e7 Attacher la base au réseau du reverse-proxy, à la demande
L'exploitant veut `db` sur `nginx_default`. Ce n'est pas requis pour que
l'application joigne la base — les deux partagent déjà `interne`, et le
journal le prouve : « password authentication failed for user "planflow" »
suppose que le nom a été résolu, la connexion établie et le dialogue
d'authentification engagé. Une absence de réseau commun donnerait une
résolution de nom impossible, pas un refus d'identifiants.

Le commentaire dit donc ce que cela coûte : la base devient joignable par
tous les conteneurs que sert ce proxy, `POSTGRES_PASSWORD` cesse d'être une
formalité, et le nom `db` publié sur un réseau partagé peut entrer en
collision avec celui d'une autre pile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-10 07:31:55 +00:00

209 lines
9.3 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
# Réaligne le mot de passe du compte d'amorçage à **chaque** démarrage.
#
# `POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un
# déploiement antérieur garde donc le mot de passe d'alors, et plus rien
# dans la pile ne peut le corriger : ni l'application, ni `db-init`, tous
# deux refusés à l'entrée. La seule position d'où la réparation est possible
# est l'intérieur de ce conteneur, où PostgreSQL accepte la socket locale
# sans mot de passe.
#
# Sans cela, une installation ayant connu deux valeurs de mot de passe ne
# redémarre plus jamais sans SQL à la main — et le symptôme, un `P1000` sur
# le rôle applicatif, désigne le mauvais coupable.
#
# Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole
# pas les variables, si bien que `:'pw'` y serait envoyé littéralement. Par
# l'entrée standard, c'est psql qui met le mot de passe entre guillemets —
# une apostrophe dans le mot de passe ne casse donc rien.
command:
- sh
- -c
- |
(
tentative=0
while [ $$tentative -lt 60 ]; do
if echo "ALTER USER \"$$POSTGRES_USER\" WITH PASSWORD :'pw';" \
| psql -q -v ON_ERROR_STOP=1 -v pw="$$POSTGRES_PASSWORD" \
-U "$$POSTGRES_USER" -d postgres >/dev/null 2>&1; then
echo "[db] Mot de passe du compte d'amorçage réaligné sur la pile."
break
fi
tentative=$$((tentative + 1))
sleep 1
done
) &
exec docker-entrypoint.sh postgres
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
# `interne` suffit à l'application : les deux services s'y joignent déjà, et
# rien de ce que fait la base ne passe par le reverse-proxy.
#
# `nginx_default` est ajouté à la demande de l'exploitant. Il faut en
# mesurer la portée : la base devient joignable par **tous** les conteneurs
# que sert ce proxy, et non plus par la seule application.
# `POSTGRES_PASSWORD` cesse alors d'être une formalité — sa valeur par
# défaut ne protège plus rien d'utile, il faut la changer.
#
# Autre effet à connaître : le nom `db` est publié sur ce réseau partagé. Un
# autre service nommé `db` qui s'y attacherait rendrait la résolution
# ambiguë pour les deux piles.
networks:
- interne
- nginx_default
# Le conteneur reste sur 5432 pour l'application, et le port hôte est publié
# sur un port non commun, configurable : plusieurs instances PostgreSQL
# coexistent sur le même serveur, et s'en tenir au 5432 par défaut les
# ferait entrer en collision.
#
# **Lié à la boucle locale**, et c'est important : la publication sert à se
# connecter depuis le serveur (sauvegarde, psql), pas depuis le réseau. Sans
# `127.0.0.1`, Docker ouvre le port sur toutes les interfaces — un jeu de
# données RH derrière un mot de passe par défaut, joignable depuis
# l'extérieur. Pour un accès distant, passer par un tunnel.
ports:
- '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:5432'
# Pose le rôle applicatif à **chaque** démarrage de la pile.
#
# `/docker-entrypoint-initdb.d` ne s'exécute qu'à la toute première
# initialisation du volume : une base dont le volume existait déjà — ou dont
# le mot de passe a changé — laisserait l'application en échec
# d'authentification P1000, sans recours autre que du SQL à la main. Le script
# est rejouable, ce service le rejoue.
#
# Les identifiants du superutilisateur restent ici : les confier au conteneur
# applicatif lui donnerait de quoi contourner la row-level security, ce que
# tout ce montage cherche précisément à empêcher.
db-init:
image: postgres:16-alpine
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_DB: ${POSTGRES_DB:-planflow}
APP_DB_USER: ${APP_DB_USER:-planflow_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
volumes:
- ./docker/init-app-role.sh:/init-app-role.sh:ro
# `entrypoint` et non `command` : l'image postgres a son propre point
# d'entrée, qui tenterait de démarrer un serveur.
entrypoint: ['sh', '/init-app-role.sh']
networks:
- interne
app:
build:
context: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
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
# Amorçage du rôle applicatif depuis le conteneur, pour les
# orchestrateurs qui n'honorent pas `depends_on` — Swarm, notamment — et
# pour les piles déployées avant l'ajout de `db-init`. Le point d'entrée
# retire ces variables avant de lancer le serveur : le processus qui sert
# les requêtes ne les voit jamais.
POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_DB: ${POSTGRES_DB:-planflow}
POSTGRES_HOST: db
APP_DB_USER: ${APP_DB_USER:-planflow_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
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: