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>
178 lines
7.8 KiB
YAML
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:
|