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 # 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: