FrankenPHP en dev c'est bien, en prod c'est encore mieux

Temps de lecture : 14 min

Déployer un projet Symfony avec FrankenPHP sur VPS : un retour d'expérience qui a survécu aux timeouts SSH.

Dans le précédent billlet, nous avons vu comment FrankenPHP a révolutionné mon environnement de développement local, remplaçant la pile Nginx/PHP-FPM par un binaire unique, simple et ultra-performant.

Mais le développement local n'est que la moitié du chemin. L'objectif ultime est la production et c'est là que je me suis entraîné à jurer dans une langue.

Passer en production impose de nouvelles contraintes : fiabilité, performance maximale, déploiements sans interruption (zéro downtime, comparé à deux heures de perte de service sur la plateforme où j'étais avant, mais c'est une autre histoire) et automatisation. Mon objectif était simple : conserver la simplicité de FrankenPHP tout en construisant un pipeline de déploiement robuste vers mon VPS, automatisé par une seule commande : make deploy-prod.

1. De l'image de dev à l'image de prod : Le Dockerfile multi-stage

La première étape est de cesser de bricoler une image de développement et de construire un véritable artefact de production. L'approche utilisée est celle de l'infrastructure immuable : l'image Docker est l'application. Elle contient le code, les dépendances et les assets compilés1 On ne modifie jamais le code sur le serveur ; on remplace simplement l'ancienne image par la nouvelle.

Notre Dockerfile est scindé en plusieurs étapes : (frankenphp_basefrankenphp_devfrankenphp_prod) pour optimiser ce processus.

# syntax=docker/dockerfile:1 

# Étape de base avec les extensions PHP communes
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_base
WORKDIR /app
VOLUME /app/var/
# Installe les dépendances système (acl, git, postgresql-client)
RUN apt-get update && apt-get install -y ... acl ... postgresql-client 
# Installe les extensions PHP (apcu, intl, opcache, zip)
RUN install-php-extensions @composer apcu intl opcache zip [cite: 2]
COPY --link devops/frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint 
...

# --- ÉTAPE DE DÉVELOPPEMENT ---
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off [cite: 1]
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" 
# Installe Xdebug et pdo_pgsql 
RUN install-php-extensions xdebug pdo_pgsql 
COPY --link devops/frankenphp/Caddyfile.dev /etc/caddy/Caddyfile [cite: 1, 5]
# Installe TOUTES les dépendances (dev incluses)
COPY --link composer.* symfony.* ./ 
RUN composer install --no-cache --prefer-dist --no-autoloader --no-scripts [cite: 6]
...
# Active le mode "watch" pour le rechargement à chaud
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ] [cite: 8]

# --- ÉTAPE DE PRODUCTION ---
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod [cite: 1]
ENV FRANKENPHP_CONFIG="import worker.Caddyfile" [cite: 1]

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" 
# Installe pdo_pgsql (pas de Xdebug !)
RUN install-php-extensions pdo_pgsql [cite: 9]
COPY --link devops/frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/ 
COPY --link devops/frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile 

# Installe les dépendances SANS les packages de dev
COPY --link composer.* symfony.* ./ 
RUN set -eux; \
	composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress 

# Copie TOUT le code source dans l'image
COPY --link . ./ 
# Supprime les fichiers devops inutiles
RUN rm -Rf devops/frankenphp 

# Optimisations de production
RUN set -eux; \
	mkdir -p var/cache var/log; \
	composer dump-autoload --classmap-authoritative --no-dev; \
	composer dump-env prod; \
	composer run-script auto-scripts-prod; \
	chmod +x bin/console; sync; 

Ce qu'il faut retenir de cette étape frankenphp_prod :

  • Héritage de frankenphp_base : On repart d'une base saine, sans Xdebug ni les configurations de développement.

  • --no-dev : La commande composer install --no-dev est cruciale. Elle exclut tous les outils d'analyse (PHPStan, Rector) et de debug, allégeant l'image et améliorant la sécurité.

  • COPY . . : L'intégralité du code de l'application est copiée dans l'image. C'est le cœur de l'infrastructure immuable.

  • Optimisations composer :

    • dump-autoload --classmap-authoritative : Crée un "catalogue" de classes ultra-rapide pour l'autoloader.

    • dump-env prod : Génère un fichier .env.local.php optimisé pour que Symfony n'ait pas à parser le .env à chaque requête.

    • run-script auto-scripts-prod : Ce script (défini dans composer.json) exécute les tâches de build, comme tailwind:build --minify et asset-map:compile, directement dans l'image.

2. Optimiser PHP et FrankenPHP pour la prod

Avoir une image propre ne suffit pas. Il faut configurer PHP et FrankenPHP pour une performance maximale.

PHP : 20-app.prod.ini

Ce fichier, copié dans l'image, contient deux directives magiques pour la performance :

; https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading
opcache.preload_user = root
opcache.preload = /app/config/preload.php
; https://symfony.com/doc/current/performance.html#don-t-check-php-files-timestamps
opcache.validate_timestamps = 0
  • opcache.validate_timestamps = 0 : C'est le gain de performance le plus important. On dit à Opcache : "Fais confiance aux fichiers. Ne vérifie jamais s'ils ont été modifiés sur le disque". Puisque notre image est immuable (elle ne change jamais après le déploiement), cette vérification est inutile et coûteuse.

  • opcache.preload : On demande à PHP de charger en mémoire, au démarrage du serveur, toutes les classes listées dans config/preload.php. Cela rend l'instanciation de services Symfony quasi instantanée.

FrankenPHP : Le mode worker

En production, on n'utilise pas la configuration Caddy de base, mais une configuration dédiée au mode Worker de FrankenPHP, via le fichier worker.Caddyfile.

worker {
	file ./public/index.php
	env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

Ce mode est la clé de la performance de FrankenPHP : il démarre l'application Symfony une seule fois et la garde en mémoire. Chaque nouvelle requête est traitée par ce même processus applicatif déjà chaud, sans avoir à re-bootstraper le framework.

Docker Compose : compose.prod.yaml

Le fichier compose.prod.yaml définit les services qui tourneront sur le VPS. Il est standalone, ce qui signifie qu'il n'a pas besoin d'autres fichiers compose.yaml pour fonctionner.

# Production Docker Compose - Standalone configuration
services:
  php:
    image: lecodeestdanslepre:latest
    restart: unless-stopped
    environment:
      SERVER_NAME: ${SERVER_NAME:-lecodeestdanslepre.fr}, php:80
      APP_ENV: prod
      APP_DEBUG: 0
      APP_SECRET: ${APP_SECRET:?APP_SECRET is required in production}
      DATABASE_URL: postgresql://${POSTGRES_USER...}:${POSTGRES_PASSWORD...}@database:5432/...
    volumes:
      - caddy_data:/data
      - caddy_config:/config
    ports:
      - target: 80
        published: ${HTTP_PORT:-80}
      - target: 443
        published: ${HTTPS_PORT:-443}
      ...
  database:
    image: postgres:${POSTGRES_VERSION:-16}-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-lecodeestdanslepre_prod}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required in production}
      POSTGRES_USER: ${POSTGRES_USER:-lecodeestdanslepre}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
      timeout: 5s
      retries: 5
      start_period: 60s
    volumes:
      - database_data:/var/lib/postgresql/data:rw

volumes:
  caddy_data:
  caddy_config:
  database_data:

Points clés :

  • image: lecodeestdanslepre:latest : Le service php utilise l'image que nous allons builder et tagger comme :latest.

  • Secrets : La configuration utilise des variables d'environnement (ex: ${APP_SECRET:?APP_SECRET is required...}). Le ? signifie que Docker Compose échouera si la variable n'est pas fournie (via un fichier .env.prod), évitant de démarrer une application non sécurisée.

  • Healthcheck : Le service database a un healthcheck qui vérifie que PostgreSQL est prêt à accepter des connexions avant que le conteneur php ne tente de s'y connecter (via depends_on dans la version du DEPLOYMENT.md).

  • Volumes : Seuls les données sont persistées (caddy_data pour les certificats SSL, database_data pour la BDD). Le code, lui, vit dans l'image.

3. L'orchestrateur : make deploy-prod

C'est ici que la magie opère. Le Makefile contient la recette complète pour prendre notre code local, le builder, et le mettre en production sur le VPS. Une seule commande : make deploy-prod.

Voici le détail de cette commande, étape par étape.

# Makefile (extrait simplifié) 

DEPLOY_HOST := monpetitponey
DEPLOY_DIR := ~/app
IMAGE_NAME := lecodeestdanslepre
IMAGE_TAG := $(shell date +%Y%m%d-%H%M%S)
BACKUP_DIR := ~/app/backups

deploy-prod: ## 🚀 Déployer en production
	@echo "📦 Version de l'image : $(IMAGE_NAME):$(IMAGE_TAG)"
	
	@echo "0. Vérification de l'environnement..."
	@ssh $(DEPLOY_HOST) "mkdir -p $(DEPLOY_DIR) $(BACKUP_DIR)" [cite: 53]

	@echo "1. Sauvegarde de l'image actuelle (rollback)..."
	@ssh $(DEPLOY_HOST) "docker images $(IMAGE_NAME):latest --format '{{.Tag}}' | \
		grep -q latest && docker tag $(IMAGE_NAME):latest $(IMAGE_NAME):rollback || echo 'Pas d image précédente'" 

	@echo "2. Construction de l'image Docker en local (AMD64 + optimisée)..."
	@docker build --no-cache -f devops/frankenphp/Dockerfile \
		-t $(IMAGE_NAME):$(IMAGE_TAG) -t $(IMAGE_NAME):latest \
		--target frankenphp_prod --platform linux/amd64 . 

	@echo "3. Sauvegarde de l'image en fichier temporaire..."
	@docker save $(IMAGE_NAME):$(IMAGE_TAG) | gzip > /tmp/$(IMAGE_NAME)-$(IMAGE_TAG).tar.gz 

	@echo "4. Transfert de l'image vers le serveur..."
	@scp -C -o ConnectTimeout=30 -o ServerAliveInterval=10 -o ServerAliveCountMax=3 \
		/tmp/$(IMAGE_NAME)-$(IMAGE_TAG).tar.gz $(DEPLOY_HOST):/tmp/ 

	@echo "5. Chargement de l'image sur le serveur..."
	@ssh -o ConnectTimeout=30 -o ServerAliveInterval=10 -o ServerAliveCountMax=3 \
		$(DEPLOY_HOST) "gunzip -c /tmp/$(IMAGE_NAME)-$(IMAGE_TAG).tar.gz | \
		docker load && rm -f /tmp/$(IMAGE_NAME)-$(IMAGE_TAG).tar.gz" 
	@rm -f /tmp/$(IMAGE_NAME)-$(IMAGE_TAG).tar.gz

	@echo "6. Tag de l'image comme 'latest' sur le serveur..."
	@ssh $(DEPLOY_HOST) "docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(IMAGE_NAME):latest" 

	@echo "7. Backup de la base de données..."
	@ssh $(DEPLOY_HOST) "cd $(DEPLOY_DIR) && docker compose ... exec -T database \
		pg_dump ... | gzip > $(BACKUP_DIR)/db-backup-$(IMAGE_TAG).sql.gz" 

	@echo "8. Transfert des fichiers de configuration..."
	@scp -o ConnectTimeout=30 ... compose.prod.yaml .env.prod $(DEPLOY_HOST):$(DEPLOY_DIR)/ [cite: 63]

	@echo "9. Redémarrage des conteneurs (up -d force recreate)..."
	@ssh $(DEPLOY_HOST) "cd $(DEPLOY_DIR) && docker compose -f compose.prod.yaml \
		--env-file .env.prod up -d --force-recreate --remove-orphans" 

	@echo "10. Attente du healthcheck (30s max)..."
	@for i in 1 2 3 4 5 6; do \
		sleep 5; \
		if ssh $(DEPLOY_HOST) "cd $(DEPLOY_DIR) && docker compose ... ps | \
			grep -q 'healthy'"; then \
			echo "✓ Conteneurs healthy"; \
			break; \
		else \
			echo "⏳ Attente healthcheck... ($$i/6)"; \
		fi; \
		if [ $$i -eq 6 ]; then \
			echo "❌ Healthcheck timeout, rollback..."; \
			$(MAKE) deploy-rollback; \
			exit 1; \
		fi; \
	done 

	@echo "11. Exécution des migrations..."
	@ssh $(DEPLOY_HOST) "cd $(DEPLOY_DIR) && docker compose ... exec -T php \
		bin/console doctrine:migrations:migrate --no-interaction" 

	@echo "13. Nettoyage des anciennes images (garde les 5 dernières)..."
	@ssh $(DEPLOY_HOST) "docker images $(IMAGE_NAME) ... | tail -n +6 | ... | xargs -r docker rmi -f" [cite: 69, 70]

	@echo "14. Nettoyage des anciens backups (garde les 10 derniers)..."
	@ssh $(DEPLOY_HOST) "ls -t $(BACKUP_DIR)/db-backup-*.sql.gz | tail -n +11 | xargs -r rm -f" [cite: 72]

Analyse du processus :

  • Étape 1 (Rollback) : Avant tout, on taggue l'image :latest actuelle en :rollback. Si le déploiement échoue, make deploy-rollback n'aura qu'à re-tagger :rollback en :latest et redémarrer. C'est notre assurance-vie.

  • Étape 2 (Build) : On build l'image en local, en ciblant frankenphp_prod et, très important, --platform linux/amd64. Cela garantit que l'image construite sur mon Mac (ARM) fonctionnera sur le VPS.

  • Étapes 3-5 (Transfert) : C'est le cœur du transfert sans registry. Au lieu d'un docker push, on fait :

    1. docker save : Exporte l'image en fichier .tar.

    2. gzip : Compresse le .tar (mon image de 900Mo passe à ~300Mo).

    3. scp : Transfère ce fichier unique vers le serveur.

    4. ssh ... "gunzip | docker load" : Décompresse à la volée sur le serveur et charge l'image dans Docker.

      Cette approche est plus fiable que de piper docker save directement dans ssh, ce qui échouait à cause de timeouts.

  • Étape 7 (Backup BDD) : On ne déploie jamais sans un backup récent. Cette commande exécute pg_dump à l'intérieur du conteneur database existant et sauvegarde le dump sur le host du VPS.

  • Étape 9 (Mise en service) : docker compose up -d --force-recreate est la bascule. Docker voit que l'image lecodeestdanslepre:latest (que nous venons de retagger à l'étape 6) a changé. Il va d'abord créer le nouveau conteneur, et une fois celui-ci prêt, il basculera le trafic et arrêtera l'ancien. C'est le "zero downtime".

  • Étape 10 (Healthcheck) : C'est la vraie sécurité. Le script attend jusqu'à 30 secondes que docker compose ps affiche l'état healthy (défini dans notre compose.prod.yaml). Si, au bout de 30 secondes, le conteneur n'est pas "healthy", le script s'arrête et lance automatiquement make deploy-rollback.

  • Étape 11 (Migrations) : Une fois le nouveau code en service et healthy, on lance les migrations BDD.

  • Étapes 13-14 (Nettoyage) : On fait le ménage pour éviter de saturer le disque du VPS.

Fiabiliser le déploiement : dompter les Timeouts SSH

Mon beau script make deploy-prod fonctionnait... puis restait figé comme un vieux fond de sauce dans une casserole.

Le problème : Read from remote host 66.66.66.666: Operation timed out37.

La cause racine : fail2ban. Mon Makefile exécute plus de 10 commandes ssh et scp en moins d'une minute. Pour fail2ban sur le VPS, c'est une attaque par force brute évidente, et il bannissait mon IP en plein milieu du déploiement.

La solution a été de repenser ma gestion SSH, avec deux optimisations majeures.

Solution 1 (locale) : le multiplexing SSH

J'ai modifié mon fichier ~/.ssh/config local pour y ajouter :

# Configuration pour déploiement lecodeestdanslepre.fr
Host monpetitponey
    HostName 66.66.666.666
    User chocolatine
    ...
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h-%p
    ControlPersist 600
    ServerAliveInterval 30
    Compression yes
  • ControlMaster auto et ControlPersist 600 : C'est la clé. La première commande ssh lecode ... ouvre une connexion maître et la garde active pendant 10 minutes (600 secondes)41.

  • Toutes les commandes ssh et scp suivantes réutilisent ce "tunnel" déjà ouvert via le socket.

  • Résultat 1 : fail2ban ne voit plus qu'une seule connexion.

  • Résultat 2 : Les connexions sont instantanées (0.03s au lieu de 1.5s).

Solution 2 (serveur) : Les keepalives

Pour éviter que la connexion ne coupe pendant le long transfert de l'image (l'étape 4 peut prendre 2-3 minutes), j'ai configuré le serveur SSH (dans /etc/ssh/sshd_config.d/) pour qu'il envoie des signaux de vie :

ClientAliveInterval 60
ClientAliveCountMax 3

Cela dit au serveur : "Envoie un paquet 'keepalive' au client toutes les 60 secondes. S'il ne répond pas 3 fois de suite, coupe la connexion". Cela maintient la connexion active indéfiniment tant que le client répond.

Le gardien du démarrage : docker-entrypoint.sh

Une dernière pièce du puzzle est le script docker-entrypoint.sh qui s'exécute à l'intérieur du conteneur avant le démarrage de FrankenPHP.

En production (APP_ENV=prod), il fait deux choses essentielles :

  1. Attendre la BDD : Il contient une boucle qui attend que la base de données soit accessible avant de continuer.

  2. Gérer les Permissions : C'est un classique de Docker. L'application (qui tourne en tant que www-data) doit pouvoir écrire dans var/. Le script setfacl accorde les droits d'écriture au bon utilisateur sur le volume var/, évitant les erreurs de cache ou de log.

Bash

#!/bin/sh
set -e

if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
	# En prod, ce "if" est faux, on saute l'installation de composer
	if [ "$APP_ENV" != "prod" ] && [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
		composer install --prefer-dist --no-progress --no-interaction 
	fi

	if grep -q ^DATABASE_URL= .env; then
		echo 'Waiting for database to be ready...'
		ATTEMPTS_LEFT_TO_REACH_DATABASE=60
		until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php ...); do
			# ... boucle d'attente ...
			sleep 1
			ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
		done
        # ... gestion d'erreur ...
	fi

	# La ligne vitale pour les permissions
	setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 
	setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 

	echo 'PHP app ready!'
fi

exec docker-php-entrypoint "$@"

Le mot de la fin

Passer en production avec FrankenPHP est un plaisir, car il simplifie la stack (plus de Nginx, plus de FPM). Mais il ne dispense pas d'une réflexion DevOps solide (et l'appel à un ami car j'ai vraiment galéré).

Cette architecture nous offre le meilleur des mondes :

  1. Performance : Grâce à FrankenPHP en mode worker et aux optimisations Opcache.

  2. Fiabilité : Grâce aux images immuables, aux healthchecks et aux backups BDD automatiques.

  3. Automatisation : Une seule commande make deploy-prod gère tout, du build au nettoyage.

  4. Robustesse : Le multiplexing SSH garantit que les déploiements ne sont plus interrompus par fail2ban ou des timeouts.

  5. Sécurité : Le rollback est instantané (make deploy-rollback) et les secrets sont gérés par des variables d'environnement.