TQ-Slator/compose.prod.yaml
Stephan Morand 4766c57dda Déclarer PHP 8.4 et alléger l'interpolation du compose
Le premier déploiement Coolify a échoué sur « Lazy loading proxies require
PHP 8.4 or higher ». La cause n'était pas dans le dépôt — Coolify avait
construit avec Nixpacks au lieu du compose — mais l'échec a mis au jour une
inexactitude réelle.

composer.json annonçait « php >=8.3 » alors que l'application exige 8.4 :
config.platform le pinçait déjà à 8.4.0, et les proxies de chargement paresseux
de Symfony ne fonctionnent pas en dessous. Toute chaîne de packaging qui lit la
contrainte pouvait donc choisir 8.3 en toute bonne foi, et échouer bien plus
loin sur un message sans rapport apparent. Contrainte remise à >=8.4.

compose.prod.yaml n'utilise plus `${VAR:?message}` : la forme arrête
correctement `docker compose`, mais le parseur de Coolify ne la comprend pas
partout. Le refus reste porté par docker/entrypoint.prod.sh, où il est testé et
où le message dit quoi faire plutôt que de citer un nom de variable.

Ajout au passage du garde-fou manquant : MARIADB_PASSWORD oubliée produisait
« mysql://tqslator:@database:… », un DSN valide au mot de passe vide, dont
l'échec remonte en erreur de droits d'accès sans jamais nommer la variable.

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

163 lines
7 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
environment:
# Coolify remplace cette variable par le domaine attribué au service et
# configure son proxy en conséquence. Sur une VM sans Coolify, retirez
# cette ligne et ajoutez `ports: ['80:80']`.
SERVICE_FQDN_PHP_80: '/'
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: