Héberger un site statique avec Caddy : le HTTPS est la partie facile
Ou : Comment un serveur qui gère seul ses certificats te laisse quand même rater ta page 404
Ce site est un site statique : Hugo fabrique des fichiers HTML, et le serveur web Caddy les envoie. J’ai longtemps servi mes sites avec Apache, puis beaucoup avec Nginx, et les certificats faisaient partie du ménage : Let’s Encrypt les délivrait pour 90 jours, et un client ACME, le plus souvent Certbot, devait les renouveler à temps. C’est une des raisons de mon passage à Caddy. Pour mes sites, les performances se valent, sa configuration est plus simple que celle de Nginx, et il a la réputation de faire le HTTPS tout seul, une réputation méritée. Depuis, je n’ai plus demandé un certificat, plus programmé un renouvellement, plus écrit de redirection vers le port 443. Le 25 octobre 2026, j’ai relu mon Caddyfile pour écrire cet article. Il faisait une trentaine de lignes et contenait quatre défauts. Aucun ne touchait aux certificats.
La partie difficile, Caddy la fait à ma place. La partie facile, il me la laisse, et c’est là que je me suis trompé.
Configurer Caddy pour un site statique : ce qu’il fait seul, ce qu’il faut écrire
Quatre lignes et un certificat
Le Caddyfile minimal d’un site statique tient en quatre lignes :
| |
Si le domaine pointe vers la machine, que les ports 80 et 443 sont ouverts et que Caddy peut écrire dans son dossier de données, le site est servi en HTTPS1. Caddy obtient un certificat chez Let’s Encrypt, se rabat sur ZeroSSL en cas d’échec, le renouvelle et redirige le HTTP vers le HTTPS. Sur ce site, la redirection répond 308 Permanent Redirect, et chaque réponse annonce HTTP/3, activé par défaut. C’est pour ça que le passage aux certificats de 45 jours ne m’a demandé aucune modification.
Tout le reste, il faut l’écrire.
Les quatre défauts de mon propre serveur
Mon Caddyfile ajoutait entre autres la compression, un en-tête HSTS, un cache long pour les images et, par coquetterie, un faux nom de serveur. Un curl -I sur quatre adresses du site suffit à tout montrer :
| |
Le déguisement ne couvrait que l’accueil. La ligne fautive était header / Server "<faux nom>". Dans un Caddyfile, un chemin est une correspondance exacte, pas un préfixe2 : / désigne l’accueil et rien d’autre. Le site se présentait sous un faux nom à la porte d’entrée et sous son vrai nom dans toutes les autres pièces. Masquer le nom de son serveur ne protège de rien, mais c’est le piège que la documentation signale en gras.
La page 404 était vide. Hugo génère un 404.html, que Caddy ne sert pas de lui-même : sans consigne, il renvoie un 404 sans corps, et le visiteur voit la page d’erreur brute de son navigateur.
Le preload du HSTS ne servait à rien. La liste de préchargement des navigateurs se demande pour un domaine de base, pas pour un sous-domaine, et exige includeSubDomains, qui engage tous les sous-domaines pour longtemps3. Sur benoit.gouthiere.be, le mot était décoratif.
Les images étaient « immuables » sans l’être. immutable dit au navigateur de ne pas revérifier le fichier pendant un an. C’est juste quand le nom change à chaque version, comme avec l’empreinte que Hugo sait ajouter aux ressources qu’il traite. Mes images gardent leur nom quand je les remplace : un visiteur garderait l’ancienne pendant un an.
Le Caddyfile corrigé
| |
Le bloc (entetes) est importé deux fois, et ce sont mes tests qui me l’ont appris. handle_errors forme sa propre chaîne de traitement4 : sans le second import, la page 404 sortait sans HSTS et avec server: Caddy. Placé dans ce bloc, le faux nom couvre enfin toutes les pages, erreurs comprises. J’ai gardé le déguisement, toujours par coquetterie ; -Server à sa place supprimerait l’en-tête au lieu de le déguiser.
Les pages HTML reçoivent un ETag, qui permet au navigateur de les revérifier sans les retélécharger ; Cache-Control: no-cache l’y obligerait à chaque visite. L’ordre des lignes, lui, est indifférent : Caddy trie les directives, et redir passe avant file_server5.
encode sans argument active zstd, puis gzip. Sur une vraie page de ce blog, de 80 121 octets, gzip descend à 19 370 octets, zstd à 20 230 avec son réglage par défaut et à 18 319 avec best. Le format compte moins que le fait de compresser : les trois pèsent environ un quart de l’original.
Le tester avant de le charger
Caddy 2.11.7, installé par go install dans un dossier jetable, a servi de banc d’essai :
| |
Avec http://localhost:18182 à la place des domaines, caddy run sert le site sans certificat, et tout s’est vérifié au curl.
Mes redirections vivent dans un fichier que Hugo génère et que Caddy importe. Un import vers un fichier absent fait échouer la validation, et un fichier importé modifié reste ignoré jusqu’au caddy reload. Un rechargement raté laisse tourner l’ancienne configuration : je l’ai vérifié en cassant exprès le fichier.
DevOps Dave : Mon serveur se fait passer pour un autre. Personne ne sait que c’est Caddy.
Security Sarah : Ouvre ton blog.
DevOps Dave : server: Caddy. Ah.
Security Sarah : Ton déguisement tenait sur une page. Et celui qui t’attaque essaie toutes les failles connues sans lire l’étiquette.
Ce que Caddy ne fera pas pour toi
Il ne protège pas son dossier de données, où dorment les certificats. Un conteneur sans volume persistant en redemande un à chaque redémarrage, et Let’s Encrypt n’en délivre que cinq par semaine pour les mêmes noms6.
Il n’écrit pas de politique de sécurité du contenu, un sujet à part sur des pages AMP, qui chargent leur moteur depuis un domaine tiers. Et il ne lit pas son journal à ta place : celui de ce site sert à mesurer l’audience sans JavaScript.
Le HTTPS a longtemps été la corvée qu’on repoussait. Caddy l’a si bien réglée qu’on ne relit plus le reste : une page 404, un en-tête, une durée de cache. Des choses simples, donc des choses qu’on ne vérifie jamais.
La documentation de l’HTTPS automatique liste ces conditions : « If Caddy cannot get a certificate from Let’s Encrypt, it will try with ZeroSSL ». ↩︎
La documentation des matchers : « Path matching is an exact match by default, not a prefix match. » ↩︎
Les conditions sont sur hstspreload.org, qui prévient que l’inscription « cannot easily be undone ». ↩︎
Selon la documentation de
handle_errors, les gestionnaires d’erreur forment « a route which is just like normal routes ». ↩︎L’ordre par défaut des directives ; le bloc
routepermet d’en imposer un autre. ↩︎Les limites de Let’s Encrypt citent parmi les causes « deleting your ACME client’s configuration data each time you deploy your application », et ajoutent : « We do not offer overrides for this limit ». ↩︎