Aller au contenu principal

FrankenPHP en prod : un binaire, quatre sous-domaines, un seul worker.

Découvrez FrankenPHP, le binaire qui remplace Nginx et PHP-FPM tout en servant plusieurs sous-domaines depuis un seul process. Avec Caddy et Symfony en tant que reverse proxy et application respectively.

11 min de lecture
Sommaire · 7

Au PHPverse 2025, une démo FrankenPHP a fait tilt, non pas pour la performance affichée — le débat « 3,5× plus rapide que FPM » est saturé sur dev.to et ne se laisse pas trancher en moins de cent benchmarks contradictoires, mais pour tout autre chose : la promesse qu'on pouvait jeter Nginx et PHP-FPM, garder le même Symfony, et finir avec un binaire. Pour moi qui suis une quiche en devops, c'était la vraie raison.

Ce site tourne là-dessus depuis octobre 2025. Depuis peu il sert quatre sous-domaines — apex public, admin EasyAdmin, serveur MCP, serveur de médias — depuis un seul process FrankenPHP, un seul Caddyfile, un seul worker Symfony, pas de reverse proxy externe, pas de pool FPM par site, pas de logrotate maison. La complexité ne disparaît pas pour autant ; elle se déplace, et c'est ce que je vais essayer de montrer.

Pourquoi un binaire plutôt que deux services

L'approche classique, c'est Nginx qui écoute le port 80/443 et PHP-FPM qui exécute le code. Deux services, une socket Unix entre les deux, deux fichiers de conf, deux unités systemd, deux process à surveiller. Pour chaque requête, le pool FPM réveille un worker, qui charge l'autoloader, qui boot Symfony, qui répond. À la fin, le process meurt et la requête suivante recommence à zéro.

FrankenPHP est écrit en Go, embarque Caddy comme serveur HTTP, et lie libphp en C. Un seul binaire ouvre les ports, parse le Caddyfile, exécute le PHP et donc un seul process à superviser, une seule conf à comprendre.

Tant qu'on s'arrête là, on a juste un truc plus propre. Le gain réel arrive plus loin.

Le mode worker

Le mode worker, c'est la décision qu'on prend dans le Caddyfile :

# devops/images/frankenphp/Caddyfile (extrait)
@frontController path /index.php
php @frontController {
    {$FRANKENPHP_SITE_CONFIG}

    worker {
        file ./public/index.php
        {$FRANKENPHP_WORKER_CONFIG}
    }
}

Le runtime Symfony qu'on branche dessus est Runtime\FrankenPhpSymfony\Runtime. À partir de ce point, public/index.php n'est plus exécuté à chaque requête. Il l'est une fois, au démarrage du conteneur. Le Kernel Symfony est instancié, le container est compilé en mémoire, les routes sont matchées une seule fois. Chaque requête entrante réveille ce process déjà chaud.

Ce que ça change pour le code : tout ce qui survit entre deux requêtes peut fuiter d'une requête à la suivante. Symfony et Doctrine sont propres sur ce point depuis longtemps, mais une propriété statique sur un service projet qui « cache » une valeur entre deux invocations devient un bug silencieux. Pareil pour un buffer qu'on n'aura pas fermé, un singleton qui garde une connexion, un état utilisateur qui traîne dans une variable de classe. PHP-FPM tuait le process à la fin, donc on pouvait être paresseux. Plus maintenant.

opcache preload — la couche d'en-dessous

Le worker démarre vite parce qu'opcache lui mâche le travail en amont. La conf vit dans cinq lignes :

; devops/images/frankenphp/conf.d/20-app.prod.ini
opcache.preload_user = root
opcache.preload = /app/config/preload.php
opcache.validate_timestamps = 0

opcache.preload charge en mémoire, avant même le démarrage du worker, les classes Symfony listées dans config/preload.php. Quand le worker démarre, elles sont déjà parsées et compilées.

opcache.validate_timestamps = 0 dit à opcache de ne plus stat()er les fichiers PHP pour vérifier s'ils ont changé. En dev, on veut l'inverse. En prod, l'image est l'application, le code ne change pas, chaque stat() est un syscall qui ne rapporte rien.

Le détail qui mérite une note : le fichier source déclare preload_user = root, et le stage frankenphp_prod_builder du Dockerfile exécute un sed qui réécrit cette ligne en www-data avant que l'image finale soit gravée. L'image prod tourne en USER www-data (UID 33), et opcache exige que preload_user corresponde à l'utilisateur effectif au moment du preload. Laisser root plante FrankenPHP au boot, avec un message peu limpide. Aligner directement à www-data dans le fichier source ne marche pas non plus, parce que le builder prod exécute cache:warmup en root et a besoin d'accéder à des artefacts qu'un www-data ne pourrait pas lire à ce moment-là. D'où le swap entre le build et le runtime.

L'image, en six stages

Le Dockerfile (devops/images/frankenphp/Dockerfile) se découpe en six stages.

  • frankenphp_caddy_builder : recompile le binaire frankenphp via xcaddy pour embarquer ses propres modules Caddy
  • frankenphp_upstream : image officielle dunglas/frankenphp:1-php8.5 + override du binaire avec celui qu'on vient de compiler
  • frankenphp_base : extensions PHP, libs système, conf commune
  • frankenphp_dev : Xdebug, mode watch, outils CLI
  • frankenphp_prod_builder : composer install --no-dev, dump-autoload, cache:warmup, collecte des libs partagées
  • frankenphp_prod : image finale, FROM debian:13-slim, rootless

Le stage qui mérite qu'on s'y arrête est le premier. L'image officielle dunglas/frankenphp embarque déjà l'encoder brotli, le hub Mercure et Vulcain — ils sont compilés dans le binaire upstream via build-static.sh du repo php/frankenphp. Ce qu'elle n'inclut pas, c'est le bouncer CrowdSec. Si on écrit crowdsec { … } dans son Caddyfile sur le binaire upstream, Caddy refuse de démarrer : la directive est inconnue.

D'où le rebuild. Le piège est ailleurs : dès qu'on passe par xcaddy build pour ajouter CrowdSec, on reconstruit le binaire à partir des sources. Tout ce qui n'est pas listé explicitement disparaît. Donc il faut réinclure brotli, Mercure et Vulcain dans le même xcaddy build, sinon on les perd au passage. D'où les quatre --with :

Dockerfile
# devops/images/frankenphp/Dockerfile (extrait, lignes 63-76)
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=1 \
    XCADDY_SETCAP=1 \
    XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
    CGO_CFLAGS="$(php-config --includes) ${CGO_CFLAGS}" \
    xcaddy build \
        --output /usr/local/bin/frankenphp \
        --with github.com/dunglas/frankenphp=./ \
        --with github.com/dunglas/frankenphp/caddy=./caddy/ \
        --with github.com/dunglas/caddy-cbrotli \
        --with github.com/dunglas/vulcain/caddy \
        --with github.com/dunglas/mercure/caddy \
        --with github.com/hslatman/caddy-crowdsec-bouncer

Un smoke check explicite plante le build si l'un des modules attendus n'est pas dans le binaire final (frankenphp list-modules | grep -qE "crowdsec"). Le coût : 5 à 10 minutes de compilation Go à froid, mis en cache ensuite par BuildKit. Le bénéfice : la directive crowdsec du Caddyfile fonctionne, et on ne s'en aperçoit pas au démarrage en prod.

CGO_CFLAGS="$(php-config --includes)" est obligatoire. Sans cet ajout, le compilo CGO ne trouve pas Zend/zend_modules.h et la compilation échoue avec un message qui se présente comme un problème de C alors que c'est un problème de pkg-config absent côté FrankenPHP. Les tags Go nobadger,nomysql,nopgx retirent les stores Caddy qu'on n'utilise pas et font gagner une trentaine de mégaoctets sur le binaire.

Un seul Caddyfile, quatre sous-domaines

Le cœur du setup tient en cinq lignes :

# devops/images/frankenphp/Caddyfile (extrait, lignes 68-72)
{$SERVER_NAME:localhost},
admin.{$APP_DOMAIN:localhost},
mcp.{$APP_DOMAIN:localhost},
media.{$APP_DOMAIN:localhost},
php:80 {
    # ... encode, vulcain, headers, route { ... }
}

Quatre hosts déclarés en virgule au début d'un seul bloc site. Caddy demande automatiquement un certificat Let's Encrypt par sous-domaine en prod. En dev, il signe ses certificats lui-même et chacun doit être accepté la première fois.

Les quatre sous-domaines servent des publics et des contenus distincts :

  • frankenphp_caddy_builder : Recompile le binaire frankenphp via xcaddy pour embarquer ses propres modules Caddy
  • frankenphp_upstream : Image officielle dunglas/frankenphp:1-php8.5 + override du binaire avec celui qu'on vient de compiler
  • frankenphp_base : Extensions PHP, libs système, conf commune
  • frankenphp_dev : Xdebug, mode watch, outils CLI
  • frankenphp_prod_builder : composer install --no-dev, dump-autoload, cache:warmup, collecte des libs partagées
  • frankenphp_prod : Image finale, FROM debian:13-slim, rootless

Côté Symfony, chaque route déclare son host : host: '%env(APP_DOMAIN)%' pour le public, host: 'admin.%env(APP_DOMAIN)%' pour l'admin, idem pour MCP et media. Le routeur Symfony répond 404 si le host de la requête ne correspond pas à celui de la route. Caddy se contente de pousser toutes les requêtes vers le même worker, le tri se fait dans config/routes/*.

L'intérêt pratique : un seul process à monitorer, un seul bin/console, un seul cache Symfony, un seul container. Quatre apps distinctes auraient demandé quatre arbres compose, quatre images, quatre déploiements, quatre caches. Là c'est un seul.

L'intérêt architectural : la séparation par host devient une décision Symfony, pas une décision d'infra. Migrer admin.* vers son propre process sera trivial le jour où ça aura du sens — il suffira de retirer le scoping côté routes et de copier le bloc site. Tant que ça n'a pas de sens, on garde le binaire unique.

C'est aussi ce qui rend ce setup invisible dans les exemples publics. Les tutoriels FrankenPHP, depuis la doc officielle jusqu'à dev.to, partent tous du postulat « un projet = un domaine ». Le worker mode rend ce postulat caduc, sans que personne ne le formule clairement.

IP filter avant CrowdSec — identitaire avant comportemental

Les sous-domaines admin. et mcp. sont privés. Le Caddyfile applique deux filtres en cascade, dans cet ordre :

# devops/images/frankenphp/Caddyfile (extrait, lignes 135-151)
@admin_or_mcp_untrusted_ip {
    header_regexp Host ^(admin|mcp)\.
    not remote_ip {$CADDY_TRUSTED_IPS_LIST:private_ranges}
}
respond @admin_or_mcp_untrusted_ip 403 {
    close
}

@notMcp not header_regexp Host ^mcp\.
crowdsec @notMcp

L'ordre n'est pas négociable. CrowdSec est un filtre comportemental : il décide en fonction de ce qu'une IP a déjà fait — force brute, scan, crawl agressif. L'IP filter est identitaire : « est-ce que cette IP fait partie de la liste de Pierre + VPS VPN ? ». Une IP inconnue par défaut ne mérite pas une consultation de la LAPI ; elle mérite un 403 immédiat, qui en plus la coupe (close) avant que Caddy ne lise le corps de la requête. Garder l'inverse coûte un appel local en plus par requête hostile, et trahit l'existence du sous-domaine au passage.

La whitelist vit dans Ansible Vault (crowdsec_whitelisted_ips), rendue en CADDY_TRUSTED_IPS_LIST dans .env.prod par devops/ansible/roles/app/templates/env.prod.j2. En dev, la variable est absente et Caddy retombe sur private_ranges (RFC1918) — admin.localhost:8443 reste accessible depuis l'host Docker.

À noter : mcp.* est exempté de CrowdSec. Claude Code burst en rafale sur le MCP au gré des sessions, et les scénarios crowdsecurity/http-bf ou crowdsecurity/http-crawl-non_statics le confondent avec un scanner. L'auth Bearer (BearerTokenAuthenticator, compare en hash_equals) ferme la porte, et un rate-limiter Symfony local (profile mcp_auth, 60 req/min/IP, pool Redis state) joue le filet contre le bruteforce sans header. Trois lignes de défense empilées dans l'ordre du moins coûteux au plus coûteux : Caddy → Symfony → DB.

Le mot de la fin

Avant FrankenPHP, ce blog aurait demandé : un nginx.conf avec ses blocs server par sous-domaine, un pool.conf PHP-FPM, une unit systemd pour FPM, une unit pour Nginx, un logrotate pour chaque, et un bouncer CrowdSec en service externe avec son propre socket. Cinq à six fichiers, deux process, et la chaîne « Nginx → socket Unix → FPM → autoloader → Symfony » à reconstituer mentalement chaque fois qu'une requête ne répond plus.

Maintenant, c'est un binaire, un Caddyfile (368 lignes), trois fichiers .ini, un entrypoint shell. Le compte est plus court, mais ce qui reste est dense. Le Caddyfile concentre la sécurité (IP filter, CrowdSec, bloc AI scrapers, 410 sur les sondes WordPress), la performance (preload, worker, brotli + zstd + gzip), le routing multi-host, les redirects historiques nettoyés post-chantier #22, et le scoping du cache HTML par host. Si on perd ce fichier, on perd la prod.

L'autre déplacement est dans la tête. Avec FPM, le code est éphémère par défaut — on pouvait écrire crade sans conséquence. Avec un worker, le code est résident, et chaque ligne qui range un état dans une statique devient une bombe à retardement. Le binaire unique est l'objet le plus visible du changement ; c'est aussi le moins coûteux à apprendre.

Un binaire pour les servir tous. Un Caddyfile pour les router. Reste à savoir si la prochaine étape — déployer cette image proprement depuis un laptop sur un VPS, sans casser les sessions, sans bannir l'IP au passage — tient elle aussi sur un seul fichier.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.