# Héberger un site statique avec Caddy : le HTTPS est la partie facile

> Caddy gère seul les certificats de ce site. Ma configuration contenait pourtant quatre défauts, tous dans la partie qu'il ne fait pas à ta place. Le Caddyfile corrigé, testé.


*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 :

```caddy
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 HTTPS[^1]. 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](/blog/lets-encrypt-certificats-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 :

```text
/                    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éfixe[^2] : `/` 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 longtemps[^3]. 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é

```caddy
# 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 traitement[^4] : 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_server`[^5].

`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 :

```bash
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 noms[^6].

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](/blog/amp-2026-avantages-inconvenients/). Et il ne lit pas son journal à ta place : celui de ce site sert à [mesurer l'audience sans JavaScript](/blog/analytics-sans-javascript-journaux-serveur/).

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](https://caddyserver.com/docs/automatic-https) liste ces conditions : « If Caddy cannot get a certificate from Let's Encrypt, it will try with ZeroSSL ».

[^2]: La [documentation des matchers](https://caddyserver.com/docs/caddyfile/matchers) : « Path matching is an exact match by default, not a prefix match. »

[^3]: Les conditions sont sur [hstspreload.org](https://hstspreload.org/), qui prévient que l'inscription « cannot easily be undone ».

[^4]: Selon la [documentation de `handle_errors`](https://caddyserver.com/docs/caddyfile/directives/handle_errors), les gestionnaires d'erreur forment « a route which is just like normal routes ».

[^5]: L'[ordre par défaut des directives](https://caddyserver.com/docs/caddyfile/directives#directive-order) ; le bloc `route` permet d'en imposer un autre.

[^6]: Les [limites de Let's Encrypt](https://letsencrypt.org/docs/rate-limits/) 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 ».



