L'image de production existait mais n'était jamais partie. Quatre manques la
rendaient inexploitable, tous silencieux — l'application démarrait dans les
quatre cas.
Le front n'était pas dans l'image
public/build et public/index.html sont gitignorés, et le Dockerfile n'avait
pas d'étage Node. L'API répondait, chaque URL de l'interface renvoyait 503 :
une panne partielle, donc plus déroutante qu'une panne franche. Étage
app_frontend ajouté, avec une vérification au build qui échoue si le front
manque.
Aucune migration n'était appliquée
Seul l'entrypoint de dev le faisait. docker/entrypoint.prod.sh applique les
migrations sous garde RUN_MIGRATIONS et refuse de démarrer sur un APP_SECRET
vide, un DATABASE_URL absent ou un BACK_OFFICE_URL sur localhost — cette
dernière valeur ne casse rien de visible, elle casse la réception des
invitations, ailleurs, plusieurs jours plus tard.
Aucun moyen de créer le premier compte
Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
Les fixtures sont une dépendance de dev. Il ne restait que l'INSERT à la
main. app:create-super-admin crée l'organisation et le premier compte, refuse
une adresse connue, exige douze caractères.
Le référentiel de langues était vide
Créé par les fixtures, donc absent en production. Sans langue source à
choisir, aucun projet ne pouvait être créé. Passé en migration : c'est une
donnée de référence, pas un jeu d'exemple.
Au passage : l'expéditeur des courriels était en dur sur no-reply@tq-slator.local,
qu'un relais SMTP réel rejette. Devenu MAILER_FROM.
compose.prod.yaml est un fichier distinct, et c'est le point qui compte : Docker
fusionne compose.override.yaml dès que le principal s'appelle compose.yaml, si
bien que Mailpit et les ports publiés seraient partis en production sans que
personne ne l'ait demandé.
Parcours vérifié sur une pile neuve : connexion, création de projet, plateforme,
push de clés, publication, clé API, fichier livré.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
7 KiB
YAML
160 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:-^$$}'
|
|
|
|
# `?` : absente, la variable arrête le déploiement au lieu de laisser
|
|
# démarrer un service à moitié configuré.
|
|
APP_SECRET: '${APP_SECRET:?openssl rand -hex 32}'
|
|
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@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:?URL publique du back-office}'
|
|
MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel, Mailpit ne part pas en production}'
|
|
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:?openssl rand -hex 32}'
|
|
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
|
|
REDIS_URL: 'redis://cache:6379'
|
|
BACK_OFFICE_URL: '${BACK_OFFICE_URL:?URL publique du back-office}'
|
|
MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel}'
|
|
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:?mot de passe MariaDB}'
|
|
MARIADB_ROOT_PASSWORD: '${MARIADB_ROOT_PASSWORD:?mot de passe root MariaDB}'
|
|
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:
|