Lesson 5/9 9 min

Automatic HTTPS with Caddy

Your Symfony app runs in a FrankenPHP container. Good news: the HTTPS server is called Caddy, and it's ALREADY inside. Three lines in the compose, one volume, and Let's Encrypt does the rest.

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 (et 443/udp pour 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, chaque docker compose up redemanderait 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 :

  1. FrankenPHP démarre, Caddy lit SERVER_NAME → il sait qu'il doit obtenir un certificat pour ce domaine.
  2. Il contacte Let's Encrypt via le protocole ACME et demande un certificat pour ton-projet.fr.
  3. 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.
  4. Le certificat est délivré, installé, et Caddy redirige automatiquement HTTP vers HTTPS.
  5. 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.

Prédis avant de lire

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.

🖥️ Terminal simulé · HTTPS avec FrankenPHP
$

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 tapes http://. 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.

Le flux ACME en 4 temps : Caddy demande un certificat à Let's Encrypt, qui répond par un challenge HTTP, visite Caddy via le DNS, puis délivre le certificat valable 90 jours et renouvelé automatiquement. Caddy (FrankenPHP) dans ton conteneur ton-projet.fr → 51.210.34.12 Let's Encrypt autorité de certification ACME / gratuit ① je veux un certif pour ton-projet.fr ② prouve-le : réponds sur http://.../.well-known/... ③ visite via DNS → IP → port 80 (challenge HTTP) ④ ✓ certif (90 j, renouvelé seul)
Le flux ACME : 4 étapes entre Caddy et Let's Encrypt. DNS propagé + port 80 ouvert = condition obligatoire.

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 (and 443/udp for 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, every docker compose up would 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:

  1. FrankenPHP starts, Caddy reads SERVER_NAME → it knows it must obtain a certificate for that domain.
  2. It contacts Let's Encrypt via the ACME protocol and requests a certificate for your-project.dev.
  3. 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.
  4. The certificate is issued, installed, and Caddy automatically redirects HTTP to HTTPS.
  5. 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.

Predict before reading on

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.

🖥️ Simulated terminal · HTTPS with FrankenPHP
$

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 type http://. 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.

The ACME flow in 4 steps: Caddy requests a certificate from Let's Encrypt, which replies with an HTTP challenge, visits Caddy via DNS, then issues the certificate valid 90 days and renewed automatically. Caddy (FrankenPHP) in your container your-project.dev → 51.210.34.12 Let's Encrypt certificate authority ACME / free ① I want a cert for your-project.dev ② prove it: reply on http://.../.well-known/... ③ visit via DNS → IP → port 80 (HTTP challenge) ④ ✓ cert (90 d, auto-renewed)
The ACME flow: 4 steps between Caddy and Let's Encrypt. Propagated DNS + open port 80 = mandatory condition.

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
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.

Une bonne explication dit : Caddy lit 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
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 ?

Le volume 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
Accepter ou rejeter 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 ?

A rejeter pour un site public. Un certificat auto-signé n'est signé par aucune autorité reconnue : les navigateurs affichent un avertissement effrayant à chaque visiteur (« Votre connexion n'est pas privée »), et la plupart partiront. Let's Encrypt est gratuit, automatisé par Caddy (zéro maintenance), et reconnu par tous les navigateurs. L'auto-signé n'a de sens que pour du test interne ou du développement local, jamais en production publique.
Dans le compose FrankenPHP, quelle variable indique à Caddy pour quel domaine demander un certificat ?
Comment Let's Encrypt vérifie que tu possèdes le domaine ?
Pourquoi le volume caddy_data est-il indispensable ?
Le challenge ACME échoue. Cause la plus probable ?
Next step

Your app is on HTTPS. But right now, every update means SSHing in and restarting by hand. In lesson 6, we wire up the CI pipeline: on every git push, GitHub runs your tests automatically, builds your Docker image — and red blocks everything.

Lesson 6: The CI pipeline →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement