Trois problèmes, un serveur déjà dans ta boîte
Ton app tourne. Tu peux y accéder en tapant http://51.210.34.12:8080 dans le navigateur. Mais il y a trois problèmes en même temps. D'abord, une IP n'est pas une adresse mémorisable, ni pour toi, ni pour tes utilisateurs. Ensuite, Chrome et Firefox affichent une icône grise et le message « Non sécurisé » dans la barre d'adresse. Enfin, tout ce qui transite entre le navigateur et ton serveur circule en clair sur internet : mots de passe, données, sessions.
Il te faut deux choses : un nom de domaine qui pointe vers ton IP, et un certificat TLS pour chiffrer la connexion. La partie pénible, c'est obtenir le certificat auprès d'une autorité de certification, l'installer, puis le renouveler tous les 90 jours. Bonne nouvelle : avec FrankenPHP, ce travail est déjà fait. Le serveur HTTPS s'appelle Caddy, et il est embarqué dans ton conteneur depuis la leçon 4. Trois lignes dans le compose, et Let's Encrypt fait le reste.
Le DNS d'abord, puis FrankenPHP fait le reste
Le DNS en bref : chez ton registrar (OVH, Gandi, Namecheap...), crée un enregistrement A qui fait pointer ton-projet.fr vers 51.210.34.12. C'est l'annuaire d'internet : quand quelqu'un tape ton domaine, les serveurs DNS le traduisent en IP. La propagation peut prendre de quelques minutes à quelques heures selon les DNS.
Dans le compose.yml de la leçon 4, trois éléments font tout le travail HTTPS :
services:
php:
image: ghcr.io/ton-org/ton-projet:latest
environment:
SERVER_NAME: ton-projet.fr # Caddy sait pour quel domaine demander le certificat
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- caddy_data:/data # les certificats survivent aux redéploiements
volumes:
caddy_data:
Ces trois lignes font précisément ceci :
SERVER_NAME: ton-projet.fr: Caddy interne lit cette variable au démarrage et sait pour quel domaine demander un certificat à Let's Encrypt.- Les ports
80/443(et443/udppour HTTP/3) : le conteneur est joignable depuis internet, et le port 80 est indispensable pour le challenge ACME (Let's Encrypt doit te visiter). - Le volume
caddy_data:/data: les certificats y sont stockés. Sans lui, chaquedocker compose upredemanderait un certificat à Let's Encrypt, et Let's Encrypt limite le nombre de demandes. Le volume les fait survivre aux redéploiements.
Voici ce qui se passe derrière, d'après la documentation officielle Caddy et la documentation FrankenPHP :
- FrankenPHP démarre, Caddy lit
SERVER_NAME→ il sait qu'il doit obtenir un certificat pour ce domaine. - Il contacte Let's Encrypt via le protocole ACME et demande un certificat pour
ton-projet.fr. - Let's Encrypt lance un challenge HTTP : il visite
http://ton-projet.fr/.well-known/acme-challenge/...et vérifie que Caddy répond correctement. C'est cette réponse qui prouve que tu contrôles ce domaine. - Le certificat est délivré, installé, et Caddy redirige automatiquement HTTP vers HTTPS.
- Avant l'expiration (les certificats Let's Encrypt durent 90 jours), Caddy renouvelle seul, sans intervention.
Le volume caddy_data doit toujours être déclaré. Teste-le : fais un docker compose down puis docker compose up -d. Si les logs ne montrent pas de nouvelle demande de certificat, le volume fonctionne.
Nginx + certbot, honnêtement : nginx avec certbot fait exactement la même chose. C'est le standard historique, ultra-documenté, présent sur la grande majorité des serveurs. La différence : nginx demande environ 15 lignes de config, plus la gestion de certbot comme service séparé. Même résultat, plus de plomberie. On apprend Caddy d'abord parce qu'il rend le mécanisme lisible. Tu reconnaîtras nginx partout ensuite, et tu comprendras ce que chaque ligne fait.
Pour que Let's Encrypt délivre le certificat de ton-projet.fr, qu'est-ce qui doit être vrai AVANT que Caddy le demande ?
Voir la réponse
Le domaine doit déjà pointer vers l'IP du VPS (enregistrement A propagé), et les ports 80 et 443 doivent être ouverts dans le firewall. Let's Encrypt vérifie que TU contrôles ce domaine en venant te visiter via un challenge HTTP sur le port 80. Si le DNS n'a pas propagé, ou si le port est fermé, la visite échoue et le certificat n'est pas délivré.
À toi : relance la stack, lis les logs, vérifie
Ton DNS pointe vers le VPS, SERVER_NAME est réglé dans le compose, le volume caddy_data est déclaré. Le terminal simulé ci-dessous t'emmène à travers les trois étapes : relancer la stack, lire les logs Caddy, et vérifier depuis l'extérieur que le HTTPS répond.
Ce que tu viens de lire dans les headers
Les trois lignes renvoyées par curl -I disent tout :
HTTP/2 200: la connexion est bien en HTTPS/2, chiffrée.server: Caddy: c'est bien Caddy qui répond, et non ton app directement.strict-transport-security: max-age=31536000: c'est le HSTS. Le navigateur est instruit de toujours utiliser HTTPS pour ce domaine pendant 1 an, même si tu tapeshttp://. Caddy l'envoie automatiquement.
Le cadenas vert dans le navigateur confirme la même chose : certificat valide, connexion chiffrée, domaine vérifié.
L'erreur n°1 : si le DNS ne pointe pas encore vers ton IP (propagation en cours), ou si le port 80 est fermé dans ton firewall, le challenge ACME échoue. Let's Encrypt doit pouvoir te joindre sur http://ton-projet.fr/.well-known/acme-challenge/... pour prouver que tu contrôles le domaine. Si ce n'est pas le cas, Caddy loggue l'erreur et le certificat n'est pas délivré. Vérifie DNS + firewall avant de déboguer Caddy.
Si tu choisis l'option Caddy-sur-l'hôte avec PHP-FPM classique, le Caddyfile tient en 5 lignes :
ton-projet.fr {
root * /opt/app/current/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
Détail qui compte avec le symlink (leçon 8) : php_fastcgi résout current vers le vrai dossier (resolve_root_symlink, actif par défaut). L'opcache ne sert donc jamais l'ancienne version. Avec nginx, il faut un contournement manuel.
Option : Caddy sur l'hôte, pour plusieurs projets
FrankenPHP embarque Caddy : pour un seul projet, tout est dans le conteneur, c'est la solution la plus simple. Mais si tu as plusieurs projets sur le même VPS, une autre architecture est courante : un Caddy sur l'hôte (pas dans Docker) qui sert de chef d'orchestre et fait un reverse_proxy vers chaque conteneur.
Logique : Caddy hôte est la porte d'entrée de TOUT le serveur. Il peut servir plusieurs compose en parallèle. Un docker compose down sur un projet ne coupe pas le HTTPS du reste. C'est aussi la bonne option si tu as des apps sans FrankenPHP (un site statique, une vieille app PHP-FPM...).
Installation (même logique qu'avec Docker à la leçon 4, un rayon apt officiel) :
# 1. Prérequis + clé GPG officielle de Caddy
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
# 2. Déclarer le rayon Caddy
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
# 3. Installer
sudo apt update
sudo apt install -y caddy
Le paquet installe aussi le service systemd de Caddy (leçon 3). Caddy tourne déjà, démarre au boot, et sera relancé s'il tombe. Vérifie :
systemctl status caddy
# ● caddy.service - Caddy
# Active: active (running)
Sa configuration vit dans /etc/caddy/Caddyfile. Pour un projet conteneurisé, le Caddyfile hôte fait un reverse_proxy vers le port interne du conteneur :
ton-projet.fr {
reverse_proxy localhost:8080
}
autre-projet.fr {
reverse_proxy localhost:8090
}
FrankenPHP embarqué ou Caddy sur l'hôte, comment choisir ? 1 projet sur le VPS = FrankenPHP suffit, tout est dans le conteneur, zéro config hôte. Plusieurs projets sur le même VPS = Caddy sur l'hôte en chef d'orchestre, chaque conteneur écoute sur un port interne différent, et Caddy distribue selon le domaine. Les deux approches utilisent ACME et Let's Encrypt de la même façon.
Three problems, one server already in the box
Your app is running. You can reach it by typing http://51.210.34.12:8080 in the browser. But there are three problems at once. First, an IP address is not memorable — not for you, not for your users. Second, Chrome and Firefox display a grey icon and the message "Not secure" in the address bar. Third, everything between the browser and your server travels in plaintext over the internet — passwords, data, sessions.
You need two things: a domain name pointing to your IP, and a TLS certificate to encrypt the connection. With FrankenPHP, that work is already done. The HTTPS server is called Caddy, and it's been inside your container since lesson 4. Three lines in the compose, and Let's Encrypt does the rest.
DNS first, then FrankenPHP does the rest
DNS in brief: at your registrar (OVH, Gandi, Namecheap...), create an A record pointing your-project.dev to 51.210.34.12. It's the internet's phone book: when someone types your domain, DNS servers translate it to an IP. Propagation can take from a few minutes to a few hours depending on DNS providers.
In the compose.yml from lesson 4, three elements do all the HTTPS work:
services:
php:
image: ghcr.io/your-org/your-project:latest
environment:
SERVER_NAME: your-project.dev # Caddy knows which domain to request a certificate for
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- caddy_data:/data # certificates survive redeployments
volumes:
caddy_data:
These three elements do precisely this:
SERVER_NAME: your-project.dev: the internal Caddy reads this variable at startup and knows which domain to request a certificate for from Let's Encrypt.- Ports
80/443(and443/udpfor HTTP/3): the container is reachable from the internet, and port 80 is required for the ACME challenge (Let's Encrypt must visit you). - The volume
caddy_data:/data: certificates are stored there. Without it, everydocker compose upwould request a new certificate from Let's Encrypt, and Let's Encrypt rate-limits requests. The volume makes them survive redeployments.
Here's what happens behind the scenes, per the official Caddy documentation and the FrankenPHP documentation:
- FrankenPHP starts, Caddy reads
SERVER_NAME→ it knows it must obtain a certificate for that domain. - It contacts Let's Encrypt via the ACME protocol and requests a certificate for
your-project.dev. - Let's Encrypt launches an HTTP challenge: it visits
http://your-project.dev/.well-known/acme-challenge/...and checks that Caddy responds correctly — proving you control that domain. - The certificate is issued, installed, and Caddy automatically redirects HTTP to HTTPS.
- Before expiry (Let's Encrypt certificates last 90 days), Caddy renews on its own, without any intervention.
The caddy_data volume must always be declared. Test it: do a docker compose down then docker compose up -d. If the logs don't show a new certificate request, the volume is working.
Honest note — nginx + certbot: nginx with certbot does exactly the same thing. It's the historical standard, massively documented, found on the vast majority of servers. The difference: nginx takes around 15 lines of config plus managing certbot as a separate service. Same result, more plumbing. We learn Caddy first because it makes the mechanism readable; you'll recognise nginx everywhere afterwards, and you'll understand what every line does.
For Let's Encrypt to issue a certificate for your-project.dev, what must be true BEFORE Caddy requests it?
Show the answer
The domain must already point to the VPS IP (A record propagated), and ports 80 and 443 must be open in the firewall. Let's Encrypt verifies that YOU control the domain by visiting it via an HTTP challenge on port 80 — if DNS hasn't propagated, or the port is blocked, the visit fails and no certificate is issued.
Your turn: restart the stack, read the logs, verify
Your DNS points to the VPS, SERVER_NAME is set in the compose, the caddy_data volume is declared. The simulated terminal below walks you through the three steps: restart the stack, read the Caddy logs, and verify from the outside that HTTPS responds.
What you just read in the headers
The three lines returned by curl -I say everything:
HTTP/2 200— the connection is over HTTPS/2, encrypted.server: Caddy— Caddy is responding (not your app directly).strict-transport-security: max-age=31536000— HSTS: the browser is told to always use HTTPS for this domain for 1 year, even if you typehttp://. Caddy sends it automatically.
The green padlock in the browser confirms the same: valid certificate, encrypted connection, verified domain.
Error #1: if DNS doesn't point to your IP yet (propagation in progress), or port 80 is closed in your firewall, the ACME challenge fails. Let's Encrypt must be able to reach you at http://your-project.dev/.well-known/acme-challenge/... to prove you control the domain. If not, Caddy logs the error and no certificate is issued — check DNS + firewall before debugging Caddy.
If you choose the host-Caddy option with classic PHP-FPM, the Caddyfile fits in 5 lines:
your-project.dev {
root * /opt/app/current/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
A detail that matters with the symlink (lesson 8): php_fastcgi resolves current to the real folder (resolve_root_symlink, on by default). So the opcache never serves the old version. With nginx, you need a manual workaround.
Option: host Caddy, for multiple projects
FrankenPHP embeds Caddy: for a single project, everything is in the container — the simplest setup. But if you have multiple projects on the same VPS, a common architecture is: a Caddy on the host (not in Docker) acting as an orchestrator, doing reverse_proxy to each container.
Logic: host Caddy is the entry point for the WHOLE server. It can serve multiple compose stacks in parallel. A docker compose down on one project doesn't cut HTTPS for the rest.
# 1. Prerequisites + Caddy's official GPG key
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
# 2. Declare the Caddy shelf
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
# 3. Install
sudo apt update
sudo apt install -y caddy
Embedded FrankenPHP or host Caddy, how to choose? 1 project on the VPS = FrankenPHP is enough, everything is in the container, zero host config. Multiple projects on the same VPS = host Caddy as orchestrator, each container listens on a different internal port, and Caddy routes by domain. Both approaches use ACME and Let's Encrypt the same way.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Raconte ce qui se passe entre Caddy (dans FrankenPHP) et Let's Encrypt quand le conteneur démarre avec un nouveau SERVER_NAME.
SERVER_NAME → contacte Let's Encrypt via ACME → LE lance un challenge HTTP (visite le domaine sur port 80 pour vérifier que tu le contrôles) → le DNS doit déjà pointer vers l'IP → LE délivre le certificat → Caddy l'installe et redirige HTTP vers HTTPS → il le renouvelle seul avant expiration, et le volume caddy_data le conserve entre les redéploiements.🧠 Rappel libre
Sans remonter : pourquoi déclare-t-on le volume caddy_data dans le compose, et que se passe-t-il si on l'oublie ?
caddy_data stocke les certificats TLS entre les redéploiements. Sans lui, chaque docker compose up crée un nouveau conteneur qui repart de zéro et demande un nouveau certificat à Let's Encrypt. Or Let's Encrypt limite le nombre de demandes par domaine et par semaine. Avec le volume, Caddy retrouve ses certificats au redémarrage et ne les redemande que quand ils approchent de l'expiration.⚖️ Juge le conseil de l'IA
L'IA te propose : « génère un certificat auto-signé, c'est plus simple et ça évite de dépendre de Let's Encrypt. » Tu acceptes, ou tu rejettes ?
Ton app est en HTTPS. Mais pour l'instant, chaque mise à jour demande de se connecter en SSH et de relancer à la main. A la leçon 6, on branche la pipeline CI : à chaque git push, GitHub lance tes tests tout seul, construit ton image Docker, et le rouge bloque tout.
Leçon 6 : La pipeline CI →