Caddy + Souin : cache HTTP et invalidation — Guide complet
Configurer le cache HTTP avec Caddy et Souin (cache-handler) : build Docker, API de gestion, surrogate keys, invalidation, et pièges à connaître.
1. Introduction
Caddy est un serveur web moderne écrit en Go, connu pour son HTTPS automatique et sa configuration simple. Souin est un système de cache HTTP conforme à la RFC 7234, conçu comme middleware pour plusieurs reverse proxies (Traefik, Caddy, NGINX, etc.).
L’intégration de Souin dans Caddy se fait via un module Go, disponible sous deux noms :
| Module | Rôle |
|---|---|
caddyserver/cache-handler | Version stable, publiée dans l’organisation Caddy |
darkweak/souin/plugins/caddy | Dépôt de développement |
Les deux contiennent le même code. En pratique, darkweak/souin/plugins/caddy est recommandé si vous avez besoin de la purge par surrogate key, car un bug connu affecte cache-handler sur ce point.
Cet article couvre le setup complet de Caddy + Souin via Docker, la configuration du cache, l’API de gestion, les surrogate keys et les pièges à connaître.
2. Build Docker de Caddy avec Souin
Souin n’est pas inclus dans l’image officielle Caddy. Il faut builder une image custom avec xcaddy :
FROM caddy:builder-alpine AS builder
RUN xcaddy build \
--with github.com/darkweak/souin/plugins/caddy \
--with github.com/darkweak/storages/otter/caddy
FROM caddy:alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Depuis Souin v1.7.0, un seul backend de stockage est supporté par build. Otter est le stockage mémoire recommandé. Les autres options (Badger, Nuts, Olric, Etcd, Redis) s’ajoutent de la même manière :
xcaddy build \
--with github.com/darkweak/souin/plugins/caddy \
--with github.com/darkweak/storages/redis/caddy # pour Redis par exemple
Source : Documentation Souin — Caddy
3. Configuration du cache dans le Caddyfile
La configuration de Souin se fait à deux niveaux : le bloc global (options par défaut) et la directive par site (activation du cache).
Configuration minimale
{
cache {
ttl 300s
stale 86400s
default_cache_control public, s-maxage=300
}
}
example.com {
cache
reverse_proxy app:8080
}
Le bloc cache {} dans les options globales définit les paramètres par défaut. La directive cache dans le bloc site active le cache pour ce site. Les deux sont nécessaires.
Options principales
| Directive | Description | Exemple |
|---|---|---|
ttl | Durée de vie en cache | 300s |
stale | Durée pendant laquelle le contenu expiré peut être servi si le backend est indisponible | 86400s |
default_cache_control | Header Cache-Control ajouté si le backend n’en fournit pas | public, s-maxage=300 |
allowed_http_verbs | Méthodes HTTP à cacher (GET par défaut) | GET POST |
Personnalisation des clés de cache
Le bloc cache_keys permet de contrôler la génération des clés de cache par pattern d’URL :
{
cache {
ttl 300s
cache_keys {
/api/.* {
disable_query # Ignorer la query string
disable_host # Ignorer le hostname
disable_scheme # Ignorer http/https
}
}
}
}
Ces options affectent la cache key (l’identifiant unique de l’entrée en cache), pas les surrogate keys — ce sont deux mécanismes distincts.
Source : Documentation Souin — Configuration
4. Exposer l’API Souin
L’API Souin est enregistrée sur le port admin de Caddy (2019), pas sur le port HTTP du site. Elle doit être explicitement activée dans la configuration.
Activer l’API
{
cache {
api {
souin # Endpoint de gestion du cache
prometheus # Métriques Prometheus (optionnel)
debug # Pprof profiling (optionnel)
}
ttl 300s
}
}
Le piège Docker : localhost
Par défaut, le port admin de Caddy n’écoute que sur localhost. Dans un conteneur Docker, cela signifie qu’il est inaccessible de l’extérieur, y compris depuis d’autres conteneurs du même réseau.
La solution : forcer l’écoute sur toutes les interfaces :
{
admin 0.0.0.0:2019
cache {
api {
souin
}
ttl 300s
}
}
Source : Caddy Community — Admin API from Docker
Endpoints disponibles
| Endpoint | Méthode | Description |
|---|---|---|
/souin-api/souin | GET | Liste des clés en cache |
/souin-api/souin | PURGE | Purge par surrogate key (via header) |
/souin-api/souin/{key} | PURGE | Purge d’une entrée spécifique |
/souin-api/metrics | GET | Métriques Prometheus |
/souin-api/debug/ | GET | Pprof (profiling) |
/souin-api/surrogate_keys | GET | Liste des surrogate keys et leurs associations |
Le basepath /souin-api est configurable :
{
cache {
api {
basepath /my-cache-api
souin {
basepath /custom-souin-path
}
}
}
}
Sécurité
Le port admin ne doit jamais être exposé publiquement. Dans un docker-compose.yml, deux approches :
Accès depuis l’hôte uniquement (debug/développement) :
ports:
- "2019:2019"
Accès entre conteneurs uniquement (production) — ne pas mapper le port :
ports:
- "80:80"
- "443:443"
# Pas de mapping 2019 → accessible uniquement via http://caddy:2019 en réseau Docker
Source : Documentation Caddy — API
5. Surrogate keys : le tagging dynamique
Le concept
Les surrogate keys permettent de regrouper des entrées de cache sous un même tag et de les invalider en une seule requête. Au lieu de connaître toutes les URLs où un produit apparaît, on invalide le tag product-123 et toutes les pages associées sont purgées.
Requête PURGE avec Surrogate-Key: product-123
Résultat :
/products/123 → purgé (tagué product-123)
/category/electronics → purgé (tagué product-123, category-elec)
/homepage → purgé (tagué product-123, promo-summer)
Headers reconnus par Souin
Souin vérifie ces headers dans l’ordre et utilise le premier non-vide :
Cache-GroupsSurrogate-KeyEdge-Cache-TagCache-Tags
Cela permet de s’adapter à différents écosystèmes (Fastly utilise Surrogate-Key, Cloudflare utilise Cache-Tag, Akamai utilise Edge-Cache-Tag).
Source : Code source — common.go
Format : virgules comme séparateur
Plusieurs surrogate keys se séparent par des virgules dans un seul header. Le code source confirme ce comportement :
// pkg/surrogate/providers/common.go
souinStorageSeparator = ","
res := strings.Split(value, s.parent.getHeaderSeparator())
for i, v := range res {
res[i] = strings.TrimSpace(v)
}
Souin trim automatiquement les espaces autour de chaque clé. Les deux écritures sont donc équivalentes :
Surrogate-Key: articles,homepage,user-42
Surrogate-Key: articles, homepage, user-42
Source : Code source — common.go
Un seul header, pas plusieurs
Souin utilise header.Get() (Go) pour lire la valeur du header, ce qui retourne uniquement la première occurrence. Si le backend renvoie plusieurs headers identiques :
Surrogate-Key: articles
Surrogate-Key: homepage
Seul articles sera pris en compte. Il faut tout mettre dans un seul header :
Surrogate-Key: articles, homepage
Source : Code source — getCandidateHeader() dans common.go
Activer le mode dynamique
Pour que Souin lise les surrogate keys renvoyées par le backend (plutôt que de se baser uniquement sur la configuration statique), il faut activer le mode dynamic :
{
cache {
cdn {
dynamic true
strategy hard
}
api {
souin
}
ttl 300s
}
}
example.com {
cache
reverse_proxy app:8080
}
Le backend ajoute alors le header dans ses réponses :
PHP :
header('Surrogate-Key: product-123, category-electronics, homepage');
Node.js / Express :
app.get("/products/:id", async (req, res) => {
const product = await Product.findById(req.params.id);
const tags = [`product-${product.id}`, `category-${product.categoryId}`];
res.set("Surrogate-Key", tags.join(", "));
res.json(product);
});
Go :
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Surrogate-Key", "product-123, category-electronics")
w.Write([]byte(`{"name": "iPhone"}`))
}
6. Surrogate keys automatiques par URI — le piège
Le comportement
En plus des surrogate keys déclarées dans les headers, Souin génère automatiquement une surrogate key basée sur le path de la requête. Ce comportement est codé en dur dans la méthode Store() :
// pkg/surrogate/providers/common.go — méthode Store()
func (s *baseStorage) Store(response *http.Response, cacheKey, uri string) error {
// ...
for _, key := range keys {
// ...
s.storeTag(key, cacheKey) // surrogate key du header
s.storeTag(uri, cacheKey) // URI = req.URL.Path, toujours ajouté
// ...
}
return nil
}
L’appel s.storeTag(uri, cacheKey) est exécuté systématiquement dans les trois branches conditionnelles de la méthode, sans aucun flag pour le désactiver.
Le uri est construit dans middleware.go :
uri := req.URL.Path
C’est uniquement le path, sans le hostname.
Source : Code source — common.go, Code source — middleware.go
Conséquence : collision multi-sites
Si Caddy sert plusieurs sites, la surrogate key / est créée pour chaque site indépendamment, mais sous le même identifiant. Purger la surrogate key / purgerait la homepage de tous les sites.
site-a.com/ → surrogate keys: ["site-a-homepage", "/"]
site-b.com/ → surrogate keys: ["site-b-homepage", "/"]
PURGE avec Surrogate-Key: /
→ Invalide les deux homepages (non désiré)
Ce qui ne marche pas
disable_surrogate_keydans la configuration CDN : cela désactive tout le système de surrogate keys (stockage + invalidation), y compris les clés explicites. Ce n’est pas une solution granulaire.cache_keys.disable_host: cela affecte la cache key (l’identifiant de l’entrée en cache), pas les surrogate keys. Ce sont deux systèmes indépendants.
Recommandations
-
Ne jamais utiliser les surrogate keys auto-générées par URI pour purger. Elles existent dans le store mais on peut les ignorer.
-
Toujours préfixer ses surrogate keys explicites avec un identifiant de site pour éviter toute ambiguïté :
// Au lieu de :
header('Surrogate-Key: homepage');
// Préférer :
header('Surrogate-Key: site-a-homepage');
- Purger uniquement via ses clés explicites :
# Correct : clé explicite, pas de collision
curl -X PURGE http://caddy:2019/souin-api/souin \
-H "Surrogate-Key: site-a-homepage"
7. Purge du cache
Par surrogate key
# Purger toutes les entrées associées à une surrogate key
curl -X PURGE http://localhost:2019/souin-api/souin \
-H "Surrogate-Key: product-123"
# Purger plusieurs groupes
curl -X PURGE http://localhost:2019/souin-api/souin \
-H "Surrogate-Key: product-123, category-electronics"
Souin retourne un 204 No Content si la purge a réussi.
Par URL
# Purger une entrée spécifique
curl -X PURGE http://localhost:2019/souin-api/souin/GET-https-example.com-%2Fproducts%2F123
# Purger par regex
curl -X PURGE http://localhost:2019/souin-api/souin/.*products.*
Lister le cache
# Lister toutes les clés en cache
curl http://localhost:2019/souin-api/souin
# Lister les surrogate keys et leurs associations
curl http://localhost:2019/souin-api/surrogate_keys
Le bug cache-handler
La purge par surrogate key ne fonctionne pas correctement avec le module caddyserver/cache-handler : la requête retourne bien un 204, les logs indiquent une purge, mais les entrées ne sont pas réellement supprimées.
Le workaround : utiliser darkweak/souin/plugins/caddy dans le build xcaddy à la place de cache-handler.
Source : Caddy Community — cache-handler doesn’t purge by surrogate key
8. Configuration complète de référence
Dockerfile
FROM caddy:builder-alpine AS builder
RUN xcaddy build \
--with github.com/darkweak/souin/plugins/caddy \
--with github.com/darkweak/storages/otter/caddy
FROM caddy:alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Caddyfile
{
# Rendre l'API admin accessible dans Docker
admin 0.0.0.0:2019
# Configuration globale du cache
cache {
api {
souin
}
cdn {
dynamic true
strategy hard
}
ttl 300s
stale 86400s
default_cache_control "public, s-maxage=300"
}
}
site-a.com {
cache
reverse_proxy app-a:8080
}
site-b.com {
cache
reverse_proxy app-b:3000
}
docker-compose.yml
services:
caddy:
build: .
ports:
- "80:80"
- "443:443"
# Ne PAS exposer 2019 en production
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
app-a:
image: my-app-a:latest
# Le backend doit renvoyer le header Surrogate-Key
app-b:
image: my-app-b:latest
volumes:
caddy_data:
caddy_config:
Vérification
# Vérifier que le cache fonctionne (le header Age apparaît au 2e appel)
curl -I https://site-a.com/
# → Age: 0 (premier appel, miss)
curl -I https://site-a.com/
# → Age: 5 (deuxième appel, hit)
# Lister les entrées en cache
curl http://caddy:2019/souin-api/souin
# Lister les surrogate keys
curl http://caddy:2019/souin-api/surrogate_keys
# Purger par tag
curl -X PURGE http://caddy:2019/souin-api/souin \
-H "Surrogate-Key: site-a-homepage"
9. Résumé des pièges
| Piège | Détail | Solution |
|---|---|---|
| API inaccessible dans Docker | Le port admin écoute sur localhost par défaut | admin 0.0.0.0:2019 |
| Headers Surrogate-Key dupliqués | header.Get() ne lit que le premier | Tout mettre dans un seul header, séparé par des virgules |
| Surrogate keys auto-générées par URI | req.URL.Path est ajouté comme surrogate key, sans le hostname | Ignorer ces clés, utiliser des clés explicites préfixées |
| Collision multi-sites | Le path / est identique pour tous les sites | Préfixer les surrogate keys par le nom du site |
| Purge par surrogate key silencieusement cassée | Bug dans caddyserver/cache-handler | Utiliser darkweak/souin/plugins/caddy |
disable_surrogate_key trop radical | Désactive tout le système, pas juste les clés auto | Pas de solution config, ignorer les clés auto |
10. Sources
- Documentation Souin — Caddy
- Documentation Souin — Configuration
- GitHub darkweak/souin
- GitHub caddyserver/cache-handler
- Code source —
common.go(surrogate keys) - Code source —
middleware.go - Surrogate keys — README
- Documentation Caddy — API admin
- Caddy Community — Admin API from Docker
- Caddy Community — cache-handler doesn’t purge by surrogate key
- Caddy Community — Purging cache via cache-handler API