Skip to content

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 frontend

Image 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 builder installe 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.
  • HEALTHCHECK intégré (sans curl : php -r sur /up).
  • Version embarquée : les jobs CI publish-* injectent les build-args VERSION_TAG / BUILD_DATE. Le Dockerfile les expose en labels OpenContainers et en ENV APP_VERSION, lu par config('app.version') et exposé sur GET /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 backend pinne --workers=${OCTANE_WORKERS:-4} — jamais auto dans 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 :

  • Appbootstrap/app.php fait 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 alors X-Forwarded-Proto: https et regénère des URLs sécurisées. Verrouillé par tests/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) impose client_max_body_size 16m; sur la location /api (ou le bloc server) du nginx de l'hôte : son défaut de 1 Mo répondrait 413 avant même que la requête n'atteigne le backend. Côté PHP, l'image embarque docker/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 de proxy_pass retire le préfixe /img, absent des chemins signés :

    nginx
    location /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 de proxy_pass remplace /pdf/ par /api/pdf/ sur le backend :

    nginx
    location /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 .env de 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-Proto n'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 :

ServiceCommandeRôle
backendoctane:start --workers=N (pinné)Serveur HTTP (8800:8000) + métriques Caddy (8803:2112)
queuequeue:work --tries=3 …Worker de file d'attente
schedulerschedule:workOrdonnanceur (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.

bash
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 -d

Rollback : 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 :

bash
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 --force

Effet 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 .env de 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 du migrate --force, soit l'extension est pré-créée par le DBA / la plateforme managée — le IF NOT EXISTS rend la migration idempotente dans les deux cas.

Contributors

No contributors

Changelog

No recent changes