Aller au contenu

DevOps 5 min de lecture

FrankenPHP, Caddy et Docker : une stack Symfony prête pour la production

Un seul conteneur pour servir PHP, gérer le HTTPS et garder Symfony en mémoire : retour d'expérience sur la stack FrankenPHP + Caddy + Docker qui fait tourner ce site, du développement au déploiement.

Schéma de la stack : Caddy pour le HTTPS, FrankenPHP en mode worker et Symfony, dans un seul conteneur Docker

Pendant des années, ma stack PHP en production ressemblait à celle de tout le monde : nginx devant, PHP-FPM derrière, certbot dans un cron, et un fichier de configuration par site qu'on n'ose plus toucher. Pour la nouvelle version de ce site, j'ai tout remplacé par un seul binaire : FrankenPHP. Voici la configuration complète, ce qu'elle apporte et les pièges à connaître.

FrankenPHP, c'est quoi exactement ?

FrankenPHP est un serveur d'application PHP écrit en Go, construit au-dessus de Caddy. Concrètement, un seul processus fait le travail de trois outils :

  • serveur web : HTTP/2, HTTP/3, compression zstd/brotli, fichiers statiques ;

  • HTTPS automatique : Caddy obtient et renouvelle les certificats Let's Encrypt tout seul ;

  • exécution de PHP : l'interpréteur est embarqué, plus besoin de PHP-FPM ni de socket FastCGI.

Et surtout, il propose un mode worker : l'application Symfony est démarrée une fois puis garde son noyau en mémoire entre les requêtes. Le conteneur de services, les routes et la configuration ne sont plus reconstruits à chaque hit.

Le Caddyfile

Toute la configuration HTTP tient dans un Caddyfile. Les valeurs entre {$…} sont des variables d'environnement, ce qui permet d'utiliser le même fichier en développement et en production :

{
	frankenphp {
		{$FRANKENPHP_CONFIG}
	}
}

{$CADDY_EXTRA_CONFIG}

{$SERVER_NAME:localhost} {
	root /app/public
	encode zstd br gzip

	# Les miniatures générées sont cachées un an
	header /media/cache/* Cache-Control "public, max-age=31536000, immutable"

	# Tout ce qui n'est pas un fichier part vers le front controller
	@phpRoute {
		not file {path}
	}
	rewrite @phpRoute index.php

	@frontController path index.php
	php @frontController {
		worker {
			file ./public/index.php
		}
	}

	file_server {
		hide *.php
	}
}

Le bloc worker active le mode worker. Côté Symfony, rien à coder : depuis Symfony 7.4, le runtime détecte FrankenPHP et s'y branche automatiquement.

Une image Docker multi-stage

Le Dockerfile part de l'image officielle dunglas/frankenphp et définit deux cibles : une pour le développement, une pour la production.

FROM dunglas/frankenphp:1-php8.5 AS frankenphp_base
WORKDIR /app
RUN install-php-extensions @composer apcu intl opcache redis zip pdo_pgsql
COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]

# --- Développement : Xdebug, sources montées en volume
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev
RUN install-php-extensions xdebug

# --- Production : dépendances et assets figés dans l'image
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod
COPY --link composer.* symfony.* ./
RUN composer install --no-dev --no-scripts --no-autoloader
COPY --link . ./
RUN composer dump-autoload --classmap-authoritative --no-dev \
    && composer dump-env prod \
    && php bin/console asset-map:compile

L'installeur install-php-extensions fourni par l'image évite d'écrire à la main les docker-php-ext-configure et dépendances système de chaque extension. L'image de production contient tout : code, vendors, assets compilés. Le serveur n'a plus qu'à la télécharger.

Docker Compose : une base, un override par environnement

Le fichier compose.yaml décrit ce qui est commun (PHP, PostgreSQL, Redis) ; un override ajoute le spécifique. En production :

# compose.prod.yaml
services:
  php:
    image: ${PHP_IMAGE}
    environment:
      APP_SECRET: ${APP_SECRET}
      SERVER_NAME: ${SERVER_NAME}
      # www.domaine.fr obtient son certificat et redirige vers le domaine nu
      CADDY_EXTRA_CONFIG: |
        www.${SERVER_NAME} {
          redir https://${SERVER_NAME}{uri} permanent
        }
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    volumes:
      - caddy_data:/data     # certificats Let's Encrypt
      - uploads:/app/public/uploads

volumes:
  caddy_data:
  uploads:

On lance la stack avec docker compose -f compose.yaml -f compose.prod.yaml up -d --wait. Avec SERVER_NAME=jdspractice.fr et un DNS qui pointe sur le serveur, Caddy obtient les certificats du domaine et de www au premier démarrage, en une dizaine de secondes.

Point crucial : le volume caddy_data. Sans lui, chaque redéploiement redemande des certificats et on atteint vite les limites de Let's Encrypt.

Les pièges du mode worker

Garder l'application en mémoire a un prix : l'état survit d'une requête à l'autre. Une propriété de service remplie pendant une requête sera encore là à la suivante, pour un autre visiteur. La règle :

  • un service qui met des données en cache dans une propriété implémente Symfony\Contracts\Service\ResetInterface : Symfony appelle reset() entre deux requêtes ;

  • pas de variables statiques ni de singletons maison ;

  • attention aux fuites mémoire : le worker redémarre après un nombre de requêtes configurable, mais autant ne pas compter dessus.

final class Settings implements ResetInterface
{
    private ?SiteSettings $settings = null;

    public function get(): SiteSettings
    {
        return $this->settings ??= $this->repository->findOneBy([]) ?? new SiteSettings();
    }

    public function reset(): void
    {
        $this->settings = null; // rechargé à la requête suivante
    }
}

Healthcheck et déploiement

Caddy expose une API d'administration sur le port 2019, utilisable comme sonde de santé :

HEALTHCHECK --start-period=60s CMD php -r 'exit(false === @file_get_contents("http://localhost:2019/metrics") ? 1 : 0);'

Grâce à elle, docker compose up --wait ne rend la main que lorsque le conteneur répond vraiment. C'est ce qui rend le déploiement continu fiable. Chez moi, pousser un tag vX.Y.Z sur GitLab lance les tests, construit l'image de production, la pousse dans le registre, puis se connecte en SSH au serveur pour faire docker compose pull && docker compose up -d --wait. Les migrations Doctrine sont jouées par l'entrypoint au démarrage du conteneur.

Le bilan

  • Moins de pièces : plus de nginx, de PHP-FPM ni de certbot ; un conteneur, un Caddyfile.

  • HTTPS sans y penser : certificats obtenus et renouvelés automatiquement, redirection HTTP → HTTPS incluse.

  • Plus rapide : le mode worker supprime le coût du démarrage de Symfony à chaque requête.

  • Même environnement partout : la même image tourne en local, dans la CI et en production.

Le seul vrai changement d'habitude concerne le mode worker : penser « application longue durée » plutôt que « script qui meurt à la fin de la requête ». Une fois ce réflexe pris, difficile de revenir en arrière.

Pour démarrer, le squelette dunglas/symfony-docker fournit une base très proche de celle-ci.

Julien DechavanneDéveloppeur web

Un projet en tête ?

Construisons quelque chose ensemble.

Démarrer la discussion →