FrankenPHP avec Symfony : Caddyfile, logs, healthchecks et rechargement des Workers

Caddyfile FrankenPHP, logs et healthcheck Docker avec Symfony
Notre configuration Caddy explicite et le trajet du healthcheck jusqu’à Symfony.

Dans la première étape de notre laboratoire, nous avons lancé Symfony avec l’image Docker officielle de FrankenPHP. Tout fonctionnait sans créer de serveur Nginx, Apache ou PHP-FPM.

Cette simplicité cachait toutefois une partie importante du fonctionnement : FrankenPHP utilisait le Caddyfile fourni par son image Docker. Dans cette deuxième étape, nous allons rendre cette configuration explicite, ajouter des en-têtes de sécurité et créer de vrais contrôles de santé pour Docker.

L’objectif reste volontairement simple : comprendre précisément quelle configuration sert notre application, sans construire immédiatement une architecture de production complexe.

Caddyfile FrankenPHP, logs et healthcheck Docker avec Symfony
Notre configuration Caddy explicite et le trajet du healthcheck jusqu’à Symfony.

Architecture du laboratoire

Nous conservons trois serveurs :

Service Port Description
php-demo 8282 Application PHP minimale
symfony-classic 8384 Symfony en mode classique
symfony-worker 8385 Symfony avec dix workers

Les trois services utilisent la même image :

dunglas/frankenphp:1-php8.5-bookworm

Le projet complet est disponible sur GitHub :

https://github.com/amine-betari/frankenphp-formation

Où FrankenPHP cherche-t-il le Caddyfile ?

Dans l’image Docker officielle, le fichier principal est situé ici :

/etc/frankenphp/Caddyfile

FrankenPHP démarre Caddy avec cette configuration. Dans notre première version, nous utilisions implicitement le fichier fourni par l’image. Cela fonctionnait, mais la configuration importante n’apparaissait pas dans notre dépôt Git.

Nous avons donc créé le fichier suivant :

docker/frankenphp/Caddyfile

Un Caddyfile minimal et explicite

Voici la configuration utilisée par le laboratoire :

{
    skip_install_trust

    frankenphp {
        {$FRANKENPHP_CONFIG}
    }
}

{$SERVER_NAME:localhost} {
    root * /app/public

    log {
        output stdout
        format console
    }

    encode zstd br gzip

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }

    php_server
}

Examinons chaque partie.

Le bloc global frankenphp

{
    skip_install_trust

    frankenphp {
        {$FRANKENPHP_CONFIG}
    }
}

Le bloc placé au début contient les options globales de Caddy et FrankenPHP.

skip_install_trust empêche Caddy d’essayer d’installer automatiquement son autorité de certification locale dans la machine. Notre laboratoire utilise uniquement HTTP sur les ports locaux, cette installation n’est donc pas nécessaire à ce stade.

La variable suivante reçoit la configuration propre à chaque conteneur :

{$FRANKENPHP_CONFIG}

Elle est vide pour le mode classique. Dans le service Worker, Docker lui transmet :

worker {
    file ./public/index.php
    num 10
    watch
}

Nous pouvons ainsi utiliser exactement le même Caddyfile dans les deux modes.

Déclarer le serveur HTTP

{$SERVER_NAME:localhost} {

La variable SERVER_NAME indique à Caddy l’adresse sur laquelle le serveur doit répondre. Dans Docker Compose, nous utilisons :

environment:
  SERVER_NAME: :80

Le conteneur écoute donc sur son port HTTP 80. Docker publie ensuite ce port sur 8384 ou 8385 selon le service.

Définir la racine publique

root * /app/public

Le projet Symfony complet est monté dans /app, mais seuls les fichiers placés dans /app/public doivent être accessibles depuis le navigateur.

Cette séparation est essentielle. Les répertoires src, config, vendor et les fichiers .env ne doivent jamais être servis directement.

Activer la compression

encode zstd br gzip

Caddy choisit automatiquement un format supporté par le navigateur :

  • Zstandard ;
  • Brotli ;
  • Gzip.

Nous avons vérifié la compression avec une requête annonçant le support de Gzip :

curl -H 'Accept-Encoding: gzip' -D - -o /dev/null \
  http://localhost:8385/products

La réponse contient :

HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding

Ajouter quelques en-têtes de sécurité

header {
    X-Content-Type-Options nosniff
    X-Frame-Options SAMEORIGIN
    Referrer-Policy strict-origin-when-cross-origin
}

Ces en-têtes apportent une première protection côté navigateur :

  • X-Content-Type-Options: nosniff limite la détection incorrecte du type d’un fichier ;
  • X-Frame-Options: SAMEORIGIN empêche un autre site d’intégrer librement les pages dans une iframe ;
  • Referrer-Policy limite les informations transmises lors d’une navigation vers un autre domaine.

Ce n’est pas une configuration de sécurité exhaustive, mais c’est une base claire et facile à vérifier.

Comprendre php_server

php_server

Cette directive relie Caddy au moteur PHP intégré à FrankenPHP. Elle permet notamment :

  • d’exécuter public/index.php ;
  • de servir les fichiers statiques ;
  • d’utiliser le front controller Symfony lorsqu’aucun fichier statique ne correspond à l’URL.

Il n’y a toujours aucun échange FastCGI et aucun processus PHP-FPM externe.

Navigateur
   ↓
Caddy intégré
   ↓
php_server
   ↓
PHP intégré à FrankenPHP
   ↓
public/index.php
   ↓
Kernel Symfony

Fichier statique ou exécution PHP ?

La directive php_server ne transmet pas inutilement tous les fichiers à PHP.

Lorsque nous demandons :

http://localhost:8282/test.txt

Caddy trouve le fichier dans /app/public et le renvoie directement :

Navigateur → Caddy → test.txt

Lorsque nous demandons /api, aucun fichier portant ce nom n’existe. La requête passe alors par le front controller :

Navigateur → Caddy → FrankenPHP → public/index.php → réponse JSON

Avec Symfony, une URL comme /products suit le même principe :

Navigateur → Caddy → FrankenPHP → public/index.php → Router Symfony → contrôleur

Les fichiers CSS, JavaScript et images peuvent donc être servis rapidement par Caddy, tandis que seules les pages dynamiques démarrent la logique PHP.

Monter le Caddyfile dans Docker Compose

Chaque service utilise le même montage en lecture seule :

volumes:
  - ./franken-symfony:/app
  - ./docker/frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro

Le suffixe :ro empêche le conteneur de modifier la configuration présente sur la machine hôte.

Le service Worker ajoute uniquement cette variable :

environment:
  FRANKENPHP_CONFIG: worker ./public/index.php 10

Au démarrage, les logs confirment le nombre de workers :

FrankenPHP started
php_version: 8.5.11
worker_threads: 10

Pourquoi ajouter une route /health ?

Savoir que le processus du conteneur existe ne suffit pas. Le serveur peut être démarré alors que Symfony ne parvient plus à répondre.

Nous avons ajouté une route très légère :

#[Route('/health', name: 'app_health', methods: ['GET'])]
public function health(): JsonResponse
{
    return new JsonResponse([
        'status' => 'ok',
        'application' => 'franken-symfony',
        'mode' => getenv('DEMO_MODE') ?: 'inconnu',
    ]);
}

Les deux modes renvoient une réponse différente :

{
  "status": "ok",
  "application": "franken-symfony",
  "mode": "classique"
}
{
  "status": "ok",
  "application": "franken-symfony",
  "mode": "worker"
}

Les URLs sont :

Configurer le healthcheck Docker

Pour éviter de répéter la configuration, Docker Compose utilise une ancre YAML :

x-healthcheck: &healthcheck
  test:
    - CMD
    - php
    - -r
    - "exit(false === @file_get_contents('http://localhost/health') ? 1 : 0);"
  interval: 10s
  timeout: 3s
  retries: 3
  start_period: 10s

Chaque service réutilise ensuite ce bloc :

healthcheck: *healthcheck

Docker appelle la route toutes les dix secondes. Si trois contrôles consécutifs échouent après la période de démarrage, le conteneur passe dans l’état unhealthy.

Vérifier l’état des services

docker compose ps

Résultat obtenu :

php-demo          Up (healthy)   0.0.0.0:8282->80/tcp
symfony-classic   Up (healthy)   0.0.0.0:8384->80/tcp
symfony-worker    Up (healthy)   0.0.0.0:8385->80/tcp

Nous pouvons aussi valider directement la syntaxe du Caddyfile :

docker compose exec symfony-classic \
  frankenphp validate \
  --config /etc/frankenphp/Caddyfile \
  --adapter caddyfile

Résultat :

Valid configuration

Observer FrankenPHP avec les logs d’accès

Pour voir les requêtes reçues, nous avons ajouté ce bloc dans le site Caddy :

log {
    output stdout
    format console
}

Les logs sont ensuite visibles avec :

docker compose logs -f symfony-worker

Une requête vers /products affiche notamment :

method: GET
uri: /products
duration: 0.018
size: 52773
status: 200

Nous pouvons ainsi connaître l’URL appelée, la méthode HTTP, le code de réponse, la durée et la taille du contenu. L’en-tête X-Powered-By: PHP/8.5.11 permet également de confirmer qu’une réponse est passée par PHP, alors qu’un fichier statique comme test.txt est servi directement par Caddy.

Le problème du code conservé en mémoire

Le mode Worker charge Symfony une fois, puis le conserve en mémoire. Cette optimisation a une conséquence importante pendant le développement.

Nous avons créé une route renvoyant une version du code :

#[Route('/demo/code-version', methods: ['GET'])]
public function codeVersion(): JsonResponse
{
    return new JsonResponse([
        'version' => 1,
        'mode' => getenv('DEMO_MODE'),
    ]);
}

Après avoir remplacé 1 par 2 sans redémarrer les conteneurs, nous avons obtenu :

Mode classique → version 2
Mode Worker    → version 1

Le mode classique relit le fichier lors de la nouvelle requête. Le Worker continue d’utiliser la classe PHP déjà chargée en mémoire.

Recharger les Workers avec watch

Pour le développement, FrankenPHP peut surveiller les fichiers et renouveler automatiquement ses threads PHP :

FRANKENPHP_CONFIG: |
  worker {
    file ./public/index.php
    num 10
    watch
  }

Après une nouvelle modification du contrôleur, FrankenPHP a écrit :

filesystem changes detected
rebooting all PHP threads
thread reboot finished

Le renouvellement des threads a pris environ 68 millisecondes. Le conteneur et Caddy sont restés actifs ; seuls les threads PHP et les Kernels Symfony ont été recréés.

Conteneur Docker → reste actif
Caddy             → reste actif
FrankenPHP        → reste actif
Threads PHP       → renouvelés
Symfony           → rechargé

watch recharge le code côté serveur. Il ne rafraîchit pas automatiquement la page du navigateur : il faut encore utiliser F5. Le rechargement automatique du navigateur est une fonctionnalité distincte.

Ce que cette étape nous apporte

Nous avons maintenant une configuration :

  • visible dans Git ;
  • identique pour les trois services ;
  • validable automatiquement ;
  • capable d’activer le Worker avec une variable ;
  • compressée ;
  • accompagnée de contrôles de santé applicatifs ;
  • observable grâce aux logs d’accès ;
  • capable de recharger automatiquement les Workers en développement.

Le laboratoire reste simple, mais son fonctionnement n’est plus caché dans l’image Docker.

Limites actuelles

Nous sommes encore dans un environnement de développement :

  • le code est monté depuis la machine hôte ;
  • APP_ENV vaut dev ;
  • le profiler Symfony est actif ;
  • le trafic local utilise HTTP ;
  • aucune image applicative immuable n’est encore construite.

Ces limites sont volontaires. La prochaine étape consistera à construire une image Docker dédiée contenant le code, les dépendances Composer et la configuration Caddy.

Ressources

Dans la série FrankenPHP avec Symfony