Déploiement
Le backend est livré sous forme d'image Docker immuable poussée sur le registre interne Spektrum, puis exécutée par docker compose sur une VM, derrière le reverse-proxy nginx de l'hôte qui termine le TLS. Une seule instance est prévue (pas de réplicas). La construction et le push de l'image sont assurés par la CI (job publish-docker dans le pipeline de tag, voir Intégration continue).
client ──https──▶ nginx (hôte, termine TLS) ──/api──▶ conteneur app (FrankenPHP/Octane :8000)
│ │
│ conteneurs queue + scheduler (même image)
├──/img──▶ conteneur imgproxy ──▶ S3 externe (originaux)
└──/────▶ conteneur frontendImage de production — docker/php/Dockerfile.prod
Distincte de l'image de dev (docker/php/Dockerfile, qui reste php artisan serve).
- Runtime : Laravel Octane sur FrankenPHP (base
dunglas/frankenphp:1-php8.5). Un seul processus est à la fois serveur HTTP et runtime PHP ; le proxy lui parle en HTTP clair. Commande :php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000. - Build multi-étapes : une étape
builderinstalle les dépendances (composer install --no-dev --optimize-autoloader) et construit l'autoloader optimisé ; l'étape finale ne garde que le runtime. Extensions PHP alignées sur l'image de dev (pdo_pgsql,pgsql,bcmath,intl,zip,pcntl,opcache)php.ini-production.
- Utilisateur non-root (
www-data) ; port non privilégié8000. HEALTHCHECKintégré (sanscurl:php -rsur/up).- Version embarquée : les jobs CI
publish-*injectent les build-argsVERSION_TAG/BUILD_DATE. Le Dockerfile les expose en labels OpenContainers et enENV APP_VERSION, lu parconfig('app.version')et exposé surGET /api/version— un contrôle de sanity pour vérifier quelle version tourne, à travers le proxy, après un déploiement.
Caches construits au démarrage, pas au build
L'entrypoint de production (docker/php/entrypoint.prod.sh) exécute config:cache, event:cache et route:cache au démarrage du conteneur, une fois l'environnement runtime présent (APP_KEY, APP_URL, DB_*…). Mettre en cache la config au build figerait l'environnement vide du build et ignorerait silencieusement les vraies valeurs injectées sur la VM. Le route:cache impose que toutes les routes pointent vers des contrôleurs (aucune closure dans routes/api.php) — d'où l'usage systématique de contrôleurs invocables.
Octane — hygiène d'état (point de vigilance)
Contrairement au modèle requête-process classique, Octane garde l'application bootée en mémoire dans des workers longue durée : l'état n'est pas réinitialisé entre les requêtes. Attention donc aux singletons qui retiennent de l'état de requête et à l'accumulation dans des propriétés statiques (fuite d'un utilisateur/tenant vers la requête suivante). C'est le compromis assumé du choix FrankenPHP/Octane ; à surveiller à chaque ajout de service applicatif à état.
Octane — workers & mémoire (incident OOM staging, juillet 2026)
octane:start --workers=auto (défaut de l'image) démarre un worker par cœur de la VM. Chaque worker garde l'application bootée en mémoire et, après son premier upload d'image, le SDK AWS (~+25-30 Mo) — dans le cgroup limité à 200M d'origine, des POST /api/images ont suffi à dépasser la limite : le kernel OOM-kill FrankenPHP (un seul processus = toute l'app tombe, les requêtes en vol reçoivent une réponse vide) et docker redémarre le conteneur en boucle.
D'où, dans docker-compose.deploy.yml (verrouillé par tests/Feature/Deploy/DeployComposeTest.php) :
- la commande du service
backendpinne--workers=${OCTANE_WORKERS:-4}— jamaisautodans un conteneur limité en mémoire ; - la limite mémoire est 384M : 4 workers plafonnent à ~225 Mo sous 16 uploads de 10 Mo concurrents (mesuré en reproduction locale), soit ~40 % de marge — nécessaire aussi pour le healthcheck (
php -r, quelques dizaines de Mo transitoires) qui vit dans le même cgroup.
OCTANE_WORKERS (optionnel, .env VM) est le levier si le budget mémoire de la VM change ; compter ~35-40 Mo par worker au repos, ~+30 Mo une fois chaud.
Reverse-proxy & génération d'URLs HTTPS
Le proxy termine le TLS et parle en HTTP au conteneur : sans configuration, Laravel ne verrait que http et générerait des URLs http:// (liens d'e-mail, URLs signées, pagination). Deux côtés à câbler :
App —
bootstrap/app.phpfait confiance au proxy via$middleware->trustProxies(at: '*', headers: …X_FORWARDED_PROTO…).at: '*'est sûr ici car le conteneur n'est joignable que par le proxy. Laravel lit alorsX-Forwarded-Proto: httpset regénère des URLs sécurisées. Verrouillé partests/Feature/Http/TrustProxiesTest.php.nginx (hôte) — doit transmettre les en-têtes :
Host,X-Forwarded-Proto $scheme(le plus important),X-Forwarded-For,X-Forwarded-Host.Taille de corps sur
/api— l'upload d'images (POST /api/images, 10 Mo) imposeclient_max_body_size 16m;sur la location/api(ou le blocserver) du nginx de l'hôte : son défaut de 1 Mo répondrait413avant même que la requête n'atteigne le backend. Côté PHP, l'image embarquedocker/php/conf.d/uploads.ini(12M/16M) — voir Stockage d'images.Route
/img/— le nginx de l'hôte route aussi les images vers le conteneur imgproxy (voir Stockage d'images) ; le slash final deproxy_passretire le préfixe/img, absent des chemins signés :nginxlocation /img/ { proxy_pass http://127.0.0.1:8802/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }Route
/pdf/— même astuce de réécriture de préfixe pour les vouchers PDF (voir Génération de PDF) : le chemin final deproxy_passremplace/pdf/par/api/pdf/sur le backend :nginxlocation /pdf/ { proxy_pass http://127.0.0.1:8800/api/pdf/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }APP_URL=https://…dans le.envde la VM — c'est la base des URLs générées hors requête (queue, scheduler, liens d'e-mail), là oùX-Forwarded-Proton'existe pas.
Sanctum fonctionne aujourd'hui par tokens bearer (sans état), donc les problématiques de cookie sécurisé ne s'appliquent pas ; ne reconsidérer SESSION_SECURE_COOKIE / SANCTUM_STATEFUL_DOMAINS que si une auth par cookie SPA était ajoutée.
Stack de déploiement sur la VM — docker-compose.deploy.yml
Trois services tirent la même image immuable (${APP_IMAGE_TAG}) ; s'y ajoutent le frontend et imgproxy :
| Service | Commande | Rôle |
|---|---|---|
backend | octane:start --workers=N (pinné) | Serveur HTTP (8800:8000) + métriques Caddy (8803:2112) |
queue | queue:work --tries=3 … | Worker de file d'attente |
scheduler | schedule:work | Ordonnanceur (remplace un cron hôte) |
frontend | (nginx embarqué) | SPA Vue servie derrière le proxy (/), 8801:80 |
imgproxy | (défaut imgproxy) | Images à la volée (/img/), 127.0.0.1:8802 ; métriques 8804:8081 |
Ports publiés : le trafic imgproxy reste loopback seul (127.0.0.1:8802), mais backend (8800), frontend (8801) et les deux ports de métriques (8803, 8804, voir Observabilité) sont publiés sur toutes les interfaces — c'est le firewall de la VM qui doit restreindre 8800/8801 au nginx de l'hôte et 8803/8804 (ainsi que 8800 pour GET /api/metrics) au host Prometheus du réseau privé. La configuration runtime vient du .env de la VM (env_file), jamais de l'image.
Particularités d'imgproxy (voir Stockage d'images) : pas de env_file partagé — seules les variables strictement nécessaires lui sont injectées (jamais APP_KEY ni DB_*) ; les originaux vivent sur un service S3 externe (aucun MinIO déployé) ; le .env de la VM doit fournir IMGPROXY_KEY / IMGPROXY_SALT (openssl rand -hex 32), IMGPROXY_S3_ENDPOINT et les AWS_* réels du fournisseur S3.
Migrations — étape gated, hors entrypoint
Les migrations ne sont pas jouées par les conteneurs. Elles sont une étape bloquante du runbook : si elles échouent, up -d n'est jamais atteint et les conteneurs en cours continuent de servir.
export APP_IMAGE_TAG=X.Y.Z
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml run --rm app php artisan migrate --force
docker compose -f docker-compose.deploy.yml up -dRollback : relancer avec l'APP_IMAGE_TAG précédent. Vérifier ensuite GET /api/version (via le proxy) pour confirmer la version en ligne.
Seed — staging uniquement
L'image staging embarque les dépendances de dev (faker), donc les seeders basés sur les factories y tournent ; l'image prod ne les a pas (--no-dev) et ne doit jamais être seedée. Sur la VM de staging, avec un tag X.Y.Z-staging.N :
export APP_IMAGE_TAG=X.Y.Z-staging.N
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml run --rm app php artisan migrate --seed --forceEffet uniquement à partir d'une image construite après la mise en place du build-arg
COMPOSER_NO_DEV(cf. CI/CD) : il faut donc cutter une nouvelle release staging pour disposer de faker dans l'image.
La base PostgreSQL est provisionnée séparément (service managé ou compose dédié) ; sa connexion vient du
.envde la VM. Voir Base de données.Prérequis PostGIS : la recherche par proximité crée l'extension par migration (
CREATE EXTENSION IF NOT EXISTS postgis). PostGIS n'est pas une extension trusted : les binaires doivent être installés sur l'hôte de la base, et soit l'utilisateur applicatif est superutilisateur au moment dumigrate --force, soit l'extension est pré-créée par le DBA / la plateforme managée — leIF NOT EXISTSrend la migration idempotente dans les deux cas.

