Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

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 :

1
2
3
4
example.com {
	root * /srv/site
	file_server
}

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 :

1
2
3
4
/                    200  server: <faux nom>
/blog/               200  server: Caddy
/images/<nom>.webp   200  cache-control: public, max-age=31536000, immutable
/page-inexistante/   404  content-length: 0

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é

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# En-têtes communs, réutilisés pour les pages d'erreur
(entetes) {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		# Un faux nom sur toutes les pages, ou -Server pour n'en donner aucun
		Server "nom-de-facade"
	}
}

example.com {
	root * /srv/site
	encode
	import entetes

	# Une semaine de cache, sans « immutable » : les noms ne changent pas
	@images path *.webp *.jpg *.png *.svg
	header @images Cache-Control "public, max-age=604800"

	# Une page renommée, avec et sans barre oblique finale
	@ancien path /ancien-article /ancien-article/
	redir @ancien /blog/nouvel-article/ permanent

	handle_errors 404 {
		import entetes
		rewrite * /404.html
		file_server
	}

	file_server
}

www.example.com {
	redir https://example.com{uri} permanent
}

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 :

1
2
3
caddy fmt --diff Caddyfile           # aucune ligne à reformater
caddy validate --config Caddyfile    # « Valid configuration »
caddy adapt --config Caddyfile --pretty   # le JSON réellement chargé

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.



  1. La documentation de l’HTTPS automatique liste ces conditions : « If Caddy cannot get a certificate from Let’s Encrypt, it will try with ZeroSSL ». ↩︎

  2. La documentation des matchers : « Path matching is an exact match by default, not a prefix match. » ↩︎

  3. Les conditions sont sur hstspreload.org, qui prévient que l’inscription « cannot easily be undone ». ↩︎

  4. Selon la documentation de handle_errors, les gestionnaires d’erreur forment « a route which is just like normal routes ». ↩︎

  5. L’ordre par défaut des directives ; le bloc route permet d’en imposer un autre. ↩︎

  6. 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 ». ↩︎