fr

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 :

ModuleRôle
caddyserver/cache-handlerVersion stable, publiée dans l’organisation Caddy
darkweak/souin/plugins/caddyDé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

DirectiveDescriptionExemple
ttlDurée de vie en cache300s
staleDurée pendant laquelle le contenu expiré peut être servi si le backend est indisponible86400s
default_cache_controlHeader Cache-Control ajouté si le backend n’en fournit paspublic, s-maxage=300
allowed_http_verbsMé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

EndpointMéthodeDescription
/souin-api/souinGETListe des clés en cache
/souin-api/souinPURGEPurge par surrogate key (via header)
/souin-api/souin/{key}PURGEPurge d’une entrée spécifique
/souin-api/metricsGETMétriques Prometheus
/souin-api/debug/GETPprof (profiling)
/souin-api/surrogate_keysGETListe 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 :

  1. Cache-Groups
  2. Surrogate-Key
  3. Edge-Cache-Tag
  4. Cache-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_key dans 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

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

  2. 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');
  1. 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ègeDétailSolution
API inaccessible dans DockerLe port admin écoute sur localhost par défautadmin 0.0.0.0:2019
Headers Surrogate-Key dupliquésheader.Get() ne lit que le premierTout mettre dans un seul header, séparé par des virgules
Surrogate keys auto-générées par URIreq.URL.Path est ajouté comme surrogate key, sans le hostnameIgnorer ces clés, utiliser des clés explicites préfixées
Collision multi-sitesLe path / est identique pour tous les sitesPréfixer les surrogate keys par le nom du site
Purge par surrogate key silencieusement casséeBug dans caddyserver/cache-handlerUtiliser darkweak/souin/plugins/caddy
disable_surrogate_key trop radicalDésactive tout le système, pas juste les clés autoPas de solution config, ignorer les clés auto

10. Sources