`POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un déploiement antérieur garde 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. Le symptôme visible — un P1000 sur `planflow_app` — désigne le mauvais coupable, et la seule issue était du SQL à la main. La seule position d'où la réparation est possible est l'intérieur du conteneur de la base, où PostgreSQL accepte la socket locale sans mot de passe. Le service y réaligne donc le compte d'amorçage sur la valeur de la pile à chaque démarrage, avant de céder la main au point d'entrée officiel. Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole pas les variables, si bien que `:'pw'` y partait littéralement — mesuré, « syntax error at or near ":" ». Par l'entrée standard, c'est psql qui met le mot de passe entre guillemets, et une apostrophe dans le mot de passe ne casse rien. Vérifié avec un mot de passe en contenant une. Éprouvé en exécutant le corps de la commande contre un vrai serveur, avec un point d'entrée factice : la reprise attend le serveur, le réalignement aboutit, et le mot de passe est bien posé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
200 lines
8.8 KiB
YAML
200 lines
8.8 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
|
|
# 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
|
|
# 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:
|