TQ-Slator/compose.prod.yaml
Stephan Morand 5863f3d887 Retirer SERVICE_FQDN_PHP_80 et exposer le port explicitement
Le domaine se pose désormais dans l'interface, sur le service `php`.

`SERVICE_FQDN_PHP_80` demandait à Coolify de GÉNÉRER un domaine en …sslip.io.
Utile pour un modèle en un clic, mais en concurrence avec un domaine renseigné
à la main : deux routes pour un même service, dont une seule correspond au DNS
réel. Le proxy répond alors 403 sur le domaine qu'il ne connaît pas — un code
qui n'apparaît nulle part dans l'application, laquelle sert le back-office sur
`/` sans jamais rien refuser.

`expose: ['80']` ajouté. L'image exposait déjà 80, mais aussi 443 et 2019
(l'endpoint d'administration de Caddy) : rien n'obligeait un proxy à deviner
lequel des trois sert l'application.

Vérifié depuis le réseau interne, avec l'en-tête Host public : `/`, `/login` et
`/api/v1/health` répondent 200, et les variables d'environnement arrivent bien
dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:07:34 +02:00

178 lines
7.8 KiB
YAML

# Pile de production — destinée à Coolify, utilisable telle quelle sur une VM.
#
# Fichier DISTINCT de compose.yaml, et c'est le point qui compte le plus ici :
# `docker compose` fusionne automatiquement compose.override.yaml dès que le
# fichier principal s'appelle compose.yaml. L'override publie des ports et
# branche Mailpit ; il partirait donc en production sans que personne ne l'ait
# demandé. Un nom explicite ferme la porte.
#
# Trois différences de fond avec la pile de développement :
# - cible d'image `app_prod` (worker mode FrankenPHP, cache figé au build),
# et non `app_dev` ;
# - aucune source montée : ce qui tourne est ce qui a été construit ;
# - aucun port publié sur l'hôte — le proxy de Coolify atteint les conteneurs
# par le réseau interne. Publier 8080 exposerait l'application en clair, à
# côté du HTTPS, ce qui annulerait ce que le proxy vient de faire.
services:
php:
build:
context: .
target: app_prod
# Le port que le proxy doit atteindre. L'image l'expose déjà, mais le
# déclarer ici lève toute ambiguïté : elle expose aussi 443 et 2019
# (l'endpoint d'administration de Caddy), et rien n'oblige un proxy à
# deviner lequel des trois sert l'application.
#
# `expose` et non `ports` : aucune publication sur l'hôte. Le proxy atteint
# le conteneur par le réseau interne, et publier 8080 en plus exposerait
# l'application en clair à côté du HTTPS.
expose:
- '80'
environment:
# PAS de `SERVICE_FQDN_PHP_80` ici, et c'est délibéré.
#
# Cette variable magique demande à Coolify de GÉNÉRER un domaine (du type
# xxxx.sslip.io) et d'y router le service. Elle rend donc service à un
# modèle en un clic, mais elle entre en concurrence avec un domaine
# renseigné à la main : on se retrouve avec deux routes pour un même
# service, dont une seule correspond au DNS réel.
#
# Le domaine se pose donc dans l'interface, sur le service `php`.
SERVER_NAME: ':80'
APP_ENV: prod
APP_DEBUG: '0'
# Le TLS est terminé par le proxy. Laisser Caddy tenter d'obtenir un
# certificat produirait des échecs ACME en boucle sur un domaine qu'il ne
# contrôle pas.
CADDY_GLOBAL_OPTIONS: 'auto_https off'
# Le proxy est le seul à parler au conteneur : sans cette liste, Symfony
# ignore X-Forwarded-Proto et fabrique des URL en http:// derrière un
# site en https:// — les liens d'invitation partiraient en clair.
# Nom imposé par Symfony (framework.trusted_proxies le lit d'office) ;
# « TRUSTED_PROXIES » tout court ne serait lu par personne.
SYMFONY_TRUSTED_PROXIES: '${SYMFONY_TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR}'
# Le back-office est servi en même origine que l'API : le navigateur
# n'émet donc aucune requête cross-origin pour lui. Cette valeur ne
# concerne que la Delivery API appelée depuis un autre domaine.
CORS_ALLOW_ORIGIN: '${CORS_ALLOW_ORIGIN:-^$$}'
# Interpolation simple, sans la forme `${VAR:?message}` : celle-ci arrête
# bien `docker compose` sur une variable manquante, mais le parseur de
# Coolify ne la comprend pas partout. Le refus est donc porté par
# docker/entrypoint.prod.sh, où il est testé — et où le message explique
# quoi faire plutôt que de lister un nom de variable.
APP_SECRET: '${APP_SECRET}'
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
REDIS_URL: 'redis://cache:6379'
# Sert à composer les liens d'invitation envoyés par e-mail. Une valeur
# fausse ne casse rien de visible ici : elle casse la réception, ailleurs,
# plus tard. L'entrypoint refuse donc localhost.
BACK_OFFICE_URL: '${BACK_OFFICE_URL}'
MAILER_DSN: '${MAILER_DSN}'
MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}'
DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}'
RELEASE_RETENTION_UNREFERENCED: '${RELEASE_RETENTION_UNREFERENCED:-5}'
INVITATION_TTL: '${INVITATION_TTL:-P7D}'
INVITATION_TTL_EXTERNAL: '${INVITATION_TTL_EXTERNAL:-P2D}'
# Ce service est le SEUL à migrer. Voir docker/entrypoint.prod.sh.
RUN_MIGRATIONS: '1'
depends_on:
database:
condition: service_healthy
cache:
condition: service_healthy
volumes:
# Captures d'écran de contexte et pièces jointes. Le seul état applicatif
# hors base : sans ce volume, un redéploiement les efface.
- app_storage:/app/var/storage
restart: unless-stopped
worker:
build:
context: .
target: app_prod
environment:
APP_ENV: prod
APP_DEBUG: '0'
APP_SECRET: '${APP_SECRET}'
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
REDIS_URL: 'redis://cache:6379'
BACK_OFFICE_URL: '${BACK_OFFICE_URL}'
MAILER_DSN: '${MAILER_DSN}'
MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}'
DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}'
# Ne migre pas : il ATTEND que le schéma soit à jour.
RUN_MIGRATIONS: '0'
depends_on:
database:
condition: service_healthy
# Attend que `php` ait fini de migrer. Sans cela le consommateur démarre
# sur une base sans table messenger_messages, échoue, redémarre — il
# finirait par s'en sortir, mais en polluant les journaux du premier
# déploiement avec des erreurs qui ne sont pas des erreurs.
php:
condition: service_healthy
volumes:
- app_storage:/app/var/storage
# `--time-limit` fait sortir le processus périodiquement : c'est le moyen
# standard de rendre à l'OS la mémoire qu'un worker PHP finit par retenir.
# `restart` le relance aussitôt.
command: ['php', 'bin/console', 'messenger:consume', 'async', '--time-limit=3600', '--memory-limit=192M', '-v']
# Le healthcheck hérité interroge l'endpoint admin de Caddy, que ce service
# ne fait pas tourner. Le consommateur est PID 1 : s'il meurt, le conteneur
# sort et Docker le relance. Une sonde ne ferait que dupliquer cela.
healthcheck:
disable: true
restart: unless-stopped
database:
image: mariadb:11.4
environment:
MARIADB_DATABASE: tqslator
MARIADB_USER: tqslator
MARIADB_PASSWORD: '${MARIADB_PASSWORD}'
MARIADB_ROOT_PASSWORD: '${MARIADB_ROOT_PASSWORD}'
command:
# Doit correspondre à default_table_options de doctrine.yaml, sinon les
# tables créées hors migration divergent en silence. Voir §3.6 de
# docs/01-architecture-proposal.md.
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_uca1400_ai_ci
- --innodb-default-row-format=dynamic
# Pas de log des requêtes sans index en production : à ce volume il
# remplirait le disque sans rien apprendre qu'on ne sache déjà.
- --slow-query-log=1
- --long-query-time=1
healthcheck:
test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
cache:
image: redis:7-alpine
# Ni RDB ni AOF : ce Redis ne porte que du cache et du verrouillage de
# connexion. Le persister donnerait l'illusion d'une donnée qu'on peut
# perdre sans conséquence — et qu'on perdra donc mal.
command: ['redis-server', '--save', '', '--appendonly', 'no', '--maxmemory', '256mb', '--maxmemory-policy', 'allkeys-lru']
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
db_data:
app_storage: