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} # Port d'écoute **dans** le conteneur. Un port non commun ne protège de # rien — sur un réseau Docker, chaque conteneur a son adresse et deux # services peuvent écouter le même port sans se gêner — mais il lève # l'ambiguïté quand plusieurs bases cohabitent derrière le même proxy. PGPORT: ${POSTGRES_PORT_INTERNAL:-5439} # 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 -p "$$PGPORT" 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 -p ${POSTGRES_PORT_INTERNAL:-5439} -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 # Ni le port du conteneur ni celui de l'hôte ne sont les ports usuels : # plusieurs instances PostgreSQL coexistent sur un même serveur, et s'en # tenir au 5432 partout invite la confusion. # # **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}:${POSTGRES_PORT_INTERNAL:-5439}' # 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 PGPORT: ${POSTGRES_PORT_INTERNAL:-5439} 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 # Port d'écoute du serveur **dans** le conteneur. C'est celui que vise # le reverse-proxy ; il doit rester le même que celui du healthcheck et # de la publication, d'où l'unique variable. PORT: ${APP_PORT_INTERNAL:-9317} # 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:${POSTGRES_PORT_INTERNAL:-5439}/${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 POSTGRES_PORT_INTERNAL: ${POSTGRES_PORT_INTERNAL:-5439} 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 interne, 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. Le port # **interne** l'est aussi, pour que la configuration du reverse-proxy # porte partout la même valeur. - '${APP_PORT:-9317}:${APP_PORT_INTERNAL:-9317}' healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://127.0.0.1:${APP_PORT_INTERNAL:-9317}/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: