Servez les assets dont le nom contient une empreinte avec Cache-Control: public, max-age=31536000, immutable, et servez le HTML avec no-cache pour les navigateurs, plus un TTL edge court et séparé pour Cloudflare. Rendez le HTML éligible au cache avec une Cache Rule, laissez Browser Cache TTL sur Respect Existing Headers et purgez le HTML en dernière étape de chaque déploiement. Le navigateur revalide alors un petit document à chaque visite, l'edge répond lui-même à la plupart de ces vérifications, et une nouvelle version est visible dès que l'appel de purge répond.
Deux politiques de cache pour deux types de fichiers
Un build statique produit deux types de fichiers. Les fichiers à empreinte, comme /_astro/index.3f9c1a.js, portent un hash du contenu dans leur nom. Quand le contenu change, l'URL change, si bien qu'une copie de l'ancienne URL peut rester un an dans n'importe quel cache. Le HTML, les flux, les sitemaps et robots.txt gardent des URL stables, donc toute copie en cache peut devenir obsolète.
Les fichiers hachés reçoivent donc la durée de vie la plus longue et n'ont jamais besoin de purge. Les URL stables reçoivent une politique navigateur qui revalide et une politique edge que l'on peut purger. L'essentiel du gain vient du premier groupe : un visiteur qui revient télécharge un petit document HTML, souvent sous forme de 304, et réutilise tous les scripts, feuilles de style et polices depuis son cache local. C'est pourquoi le cache compte parmi les leviers les moins coûteux pour le Time to First Byte et le Largest Contentful Paint, comme l'explique le guide Core Web Vitals.
Deux règles de déploiement sécurisent ce schéma. Envoyez les nouveaux fichiers hachés avant que le HTML ne bascule sur la nouvelle version. Conservez les fichiers hachés de la version précédente tant qu'un HTML qui les référence peut encore se trouver dans un cache, car un onglet ouvert avant le déploiement demandera les anciens noms.
TTL navigateur et TTL edge sont deux horloges distinctes
Le cache du navigateur appartient au visiteur, et rien de ce que vous faites après la réponse ne peut en retirer une entrée. Le cache edge appartient à Cloudflare, et l'API de purge le vide en quelques secondes. Réservez donc un TTL navigateur long aux URL dont le contenu ne change jamais. Pour le HTML, no-cache autorise le navigateur à stocker la page mais impose une requête conditionnelle avant chaque réutilisation.
Le TTL edge peut venir de s-maxage, de Cloudflare-CDN-Cache-Control ou CDN-Cache-Control, ou d'une Cache Rule. Comme on peut le purger, le TTL edge du HTML est un filet de sécurité, pas le mécanisme de mise à jour. Si une purge échoue, l'obsolescence maximale à l'edge vaut la durée de fraîcheur plus la fenêtre stale-while-revalidate : avec max-age=300, stale-while-revalidate=60, cela fait 360 secondes.
Vérifiez d'abord un réglage de zone. Browser Cache TTL vaut quatre heures par défaut sur toutes les offres, et Cloudflare remplace les durées d'origine inférieures à cette valeur. Un HTML censé être revalidé pourrait alors rester des heures dans les navigateurs, hors de portée de toute purge. Réglez Browser Cache TTL sur Respect Existing Headers, comme le décrit la documentation sur les TTL edge et navigateur.
Comment Cloudflare lit s-maxage, stale-while-revalidate et stale-if-error
Les navigateurs ignorent s-maxage, donc Cache-Control: public, max-age=0, s-maxage=300 ressemble à l'en-tête évident pour le HTML, et Cloudflare l'utilise bien comme TTL edge. Le piège se trouve dans la RFC 9111 : s-maxage porte aussi la sémantique de proxy-revalidate, si bien qu'un cache partagé ne doit pas servir la réponse périmée sans l'avoir revalidée.
Sur les offres Free, Pro et Business, Origin Cache Control est toujours actif et Cloudflare applique cette règle. Sa documentation sur la revalidation cite s-maxage, must-revalidate, proxy-revalidate et no-cache parmi les directives qui désactivent le service de copies périmées. À côté de stale-while-revalidate, elles transforment UPDATING en EXPIRED, et le visiteur attend l'origine. Ces mêmes directives font ignorer stale-if-error par Cloudflare. Un en-tête tel que max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 n'obtient que le TTL edge de 300 secondes, rien de plus.
Quand stale-while-revalidate s'applique, la revalidation est asynchrone : la première requête après expiration reçoit la copie périmée avec cf-cache-status: UPDATING pendant que Cloudflare la rafraîchit en arrière-plan. stale-if-error ne s'applique qu'aux réponses 5xx de l'origine. Always Online désactive les deux directives, et les valeurs de TTL doivent être des entiers.
Pour donner des politiques différentes au navigateur et à l'edge sans s-maxage, utilisez un en-tête ciblé. Cloudflare évalue Cloudflare-CDN-Cache-Control, puis CDN-Cache-Control, puis Cache-Control. Quand un en-tête CDN est présent, Cache-Control arrive intact au navigateur et n'influence pas l'edge, et Cloudflare ne transmet pas Cloudflare-CDN-Cache-Control en aval. Pour le HTML :
Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400
Le navigateur revalide à chaque fois. Cloudflare garde la page cinq minutes, la sert périmée jusqu'à une minute pendant le rafraîchissement, et sert la dernière copie valide pendant une journée si l'origine tombe. Les règles de priorité figurent dans la documentation CDN-Cache-Control.
Cache Rules : respecter l'origine ou la remplacer
Par défaut, Cloudflare décide de l'éligibilité selon l'extension du fichier et ne met en cache ni le HTML ni le JSON. Une URL comme /writing/post/ n'a pas d'extension, donc sans règle chaque requête de page est DYNAMIC et part vers l'origine.
Une Cache Rule avec Eligible for cache propose trois modes d'Edge TTL. respect_origin suit vos en-têtes et, en leur absence, applique les valeurs par défaut, par exemple 120 minutes pour une réponse 200. bypass_by_default suit les en-têtes et ne met rien en cache sans eux. override_origin ignore les en-têtes et impose un TTL, avec un minimum qui dépend de l'offre : 2 heures sur Free, 1 heure sur Pro et 1 seconde sur Business et Enterprise. Le Browser TTL peut respecter l'origine, la remplacer ou contourner le cache.
Quand l'origine envoie des en-têtes réfléchis, respectez-les. Ce ruleset pour la phase http_request_cache_settings rend l'hôte éligible, puis exclut les chemins dynamiques. Pour les réglages de cache, la dernière règle correspondante l'emporte, donc la règle de bypass vient en dernier.
{
"rules": [
{
"description": "Cache the site according to origin headers",
"expression": "http.host eq \"example.com\"",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "respect_origin" },
"browser_ttl": { "mode": "respect_origin" }
}
},
{
"description": "Never cache API routes and previews",
"expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
"action": "set_cache_settings",
"action_parameters": { "cache": false }
}
]
}
N'ajoutez pas http.request.method eq "GET" à une règle de cache. Cloudflare prévient que la purge d'une URL unique peut échouer quand une règle ne correspond qu'à GET, car les requêtes de purge utilisent en interne une autre méthode. La liste complète des options se trouve dans les réglages des Cache Rules.
En-têtes Nginx sans le piège de l'héritage de add_header
Nginx hérite des directives add_header du niveau englobant uniquement si le niveau courant n'en définit aucune. Dès qu'un location ajoute son propre Cache-Control, tous les en-têtes du niveau server y disparaissent sans bruit, en-têtes de sécurité compris.
server {
add_header X-Content-Type-Options "nosniff" always;
location ^~ /_astro/ {
# X-Content-Type-Options is no longer sent for this location.
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
La solution portable consiste à placer les en-têtes communs dans un snippet inclus avec include dans chaque bloc qui possède son propre add_header. Depuis Nginx 1.29.3, et donc dans la branche stable 1.30, add_header_inherit merge ajoute plutôt les en-têtes hérités. Avec merge, gardez Cache-Control hors du bloc server, sinon les location l'enverront deux fois.
Selon la documentation du module headers, add_header sans always ne s'applique qu'aux réponses 200, 201, 204, 206, 301, 302, 303, 304, 307 et 308. Utilisez always pour les en-têtes de sécurité et pour no-store sur les pages d'erreur, jamais pour des en-têtes de cache longue durée, sinon un 404 dû à une faute de frappe dans un nom d'asset devient immutable pour un an. Évitez aussi de mélanger expires et add_header Cache-Control, ce qui produit deux champs d'en-tête. Voici un bloc server pour un build statique, sans TLS ni journalisation :
server {
listen 443 ssl;
http2 on;
server_name example.com;
root /srv/example.com/current;
index index.html;
gzip on;
gzip_vary on;
gzip_static on;
gzip_types text/css application/javascript application/json
application/xml application/rss+xml image/svg+xml text/plain;
location ^~ /_astro/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
location ~* \.(?:xml|txt)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=300";
add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
add_header Cache-Tag "html";
try_files $uri =404;
}
location / {
include snippets/security-headers.conf;
add_header Cache-Control "no-cache";
add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
add_header Cache-Tag "html";
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
include snippets/security-headers.conf;
add_header Cache-Control "no-store" always;
}
}
gzip_static exige une compilation avec --with-http_gzip_static_module, visible dans la sortie de nginx -V. L'origine ne doit accepter que les connexions de Cloudflare, pour que personne ne contourne le cache. Le guide de durcissement d'un VPS Linux couvre la partie pare-feu.
ETag, Last-Modified et revalidation
Avec etag on, actif par défaut, Nginx envoie ETag et Last-Modified pour les fichiers statiques et répond 304 aux requêtes conditionnelles. Son ETag est construit à partir de la date de modification et de la taille, pas d'un hash du contenu. Un déploiement qui réécrit tous les fichiers change donc tous les ETag, et la première revalidation après une mise en production renvoie un 200 complet. Si plusieurs serveurs d'origine servent la même version, leurs fichiers doivent avoir les mêmes dates, par exemple via rsync -a, sinon le résultat de la revalidation dépendra du serveur qui répond.
Tant que sa copie est fraîche, Cloudflare répond lui-même aux revalidations des navigateurs. Après expiration, il envoie une requête conditionnelle à Nginx, et un 304 renouvelle le TTL sans transférer le corps. Quand Cloudflare change l'encodage, il affaiblit un ETag fort en W/"...", ce qui est sans conséquence puisque If-None-Match utilise la comparaison faible. N'activez Respect Strong ETags que si un client a vraiment besoin de validateurs exacts à l'octet près.
Compression : gzip à l'origine, Brotli et Zstandard à l'edge
Cloudflare demande accept-encoding: br, gzip à l'origine et peut réencoder ce qu'il reçoit. Vers les visiteurs, il sert gzip, Brotli ou Zstandard selon Accept-Encoding, l'offre et les Compression Rules. Par défaut, les zones Free privilégient Zstandard, Pro et Business privilégient Brotli, et Enterprise utilise gzip. Seules les réponses 200, 403 et 404 sont compressées, comme l'indique la documentation sur la compression.
Pour une origine derrière Cloudflare, gzip suffit. Précompressez les fichiers texte au build pour gzip_static, ou laissez gzip on compresser à la volée. gzip_types ne contient par défaut que text/html : listez les autres types texte et gardez gzip_vary on. Brotli et Zstandard ne font pas partie des modules de nginx.org et nécessitent des modules tiers. Évitez Cache-Control: no-transform sur le texte, car Cloudflare ne compresserait plus une réponse non compressée.
Cookies, query strings et clé de cache
La clé de cache par défaut de Cloudflare contient le schéma, l'hôte, le chemin et la query string complète, ainsi que quelques en-têtes de requête comme Origin. Les cookies n'en font pas partie, donc une URL en cache ne doit jamais varier selon un cookie. Placez les pages connectées, les paniers, une API ou le backend d'un agent derrière une règle de bypass avec private ou no-store, ou sur un hôte séparé. Le guide d'architecture d'agent IA en production montre ce que contient généralement un tel chemin de requête dynamique.
Un en-tête de réponse Set-Cookie casse le cache. Avec Eligible for cache, Cloudflare conserve le cookie et ne stocke pas la réponse, si bien que chaque requête est un MISS. Vérifiez qu'aucun module ni répartiteur de charge n'ajoute de cookies aux fichiers statiques, ou retirez-les avec une Cache Response Rule.
Chaque query string distincte crée une entrée séparée, donc les liens avec ?utm_source= commencent par un miss. Cela coûte du taux de hit, pas de la justesse. Le niveau de cache Ignore Query String ne s'applique qu'aux extensions de fichiers statiques, et les clés personnalisées par paramètre dépendent de l'offre. Sort query string est disponible sur toutes les offres.
Stratégies de purge et purge depuis la CI
Depuis avril 2025, toutes les offres disposent de toutes les méthodes de purge :
- Single URL supprime des URL exactes, jusqu'à 100 par requête (500 sur Enterprise), quand le build sait ce qui a changé.
- Prefix supprime tout ce qui se trouve sous un chemin comme
example.com/writing/, variantes de query string comprises. - Tag supprime tous les objets dont la réponse portait un en-tête
Cache-Tagcorrespondant, que Cloudflare retire avant que le visiteur ne le voie. Étiquetez le HTML, les flux et les sitemaps avechtml, et un seul appel purge toutes les URL stables tandis que les assets hachés restent en cache. - Hostname supprime tout pour un hôte.
- Everything vide toute la zone et envoie tout le trafic vers l'origine jusqu'à ce que le cache se remplisse, gardez-la donc en dernier recours.
Les purges par hostname, tag, prefix et everything partagent une limite par compte : 5 requêtes par minute sur Free, 5 par seconde sur Pro, 10 par seconde sur Business et 50 par seconde sur Enterprise, selon la documentation de purge. Utilisez un jeton d'API limité à la permission Cache Purge sur une seule zone, stocké comme secret de CI :
#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"
# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"
# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"
# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags":["html"]}' </dev/null \
| jq -e '.success == true' >/dev/null
Les sites plus volumineux ont intérêt à envoyer le build dans un répertoire de version et à basculer un lien symbolique de façon atomique. Une réponse de purge réussie signifie seulement que la requête a été acceptée : terminez donc le job en récupérant une page modifiée et en vérifiant un marqueur de build, comme le hash du commit.
Vérifier avec curl -I, cf-cache-status et Age
curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
| grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'
Cloudflare convertit HEAD en GET pour les requêtes cachables, donc curl -I remplit aussi le cache. La première réponse doit afficher MISS, la seconde HIT avec Age, le nombre de secondes depuis la mise en cache ou la dernière revalidation. La documentation des réponses du cache définit chaque statut :
DYNAMIC: requête non éligible, en général parce qu'aucune règle ne couvre le HTML ou que Development Mode est actif.BYPASS: requête éligible, maisno-store,private,Set-CookieouVary: *ont rendu la réponse non cachable.UPDATING: copie périmée servie pendant une revalidation en arrière-plan.EXPIRED: copie périmée récupérée à nouveau de façon synchrone. Si vous attendiezUPDATING, cherchezs-maxage,must-revalidateouno-cache.REVALIDATED: l'origine a confirmé la copie par un 304 pendant que la requête attendait.STALE: l'origine n'a pas répondu et l'ancienne copie a été servie.
Après une purge, attendez-vous à MISS, ou à EXPIRED avec Tiered Cache et après une purge everything. Pour tester Nginx seul, interrogez l'origine depuis un hôte autorisé avec curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/, puis ajoutez -H 'If-None-Match: "<etag>"' et attendez un 304.
En-têtes recommandés par type de fichier
| Type de fichier | Exemple | Cache-Control | Politique edge | Après un déploiement |
|---|---|---|---|---|
| JS, CSS, polices, images à empreinte | /_astro/app.3f9c1a.js |
public, max-age=31536000, immutable |
Respecter l'origine | Rien |
| Pages HTML | /writing/post/ |
no-cache |
max-age=300, stale-while-revalidate=60, stale-if-error=86400 |
Purger le tag html |
| Flux, sitemaps, robots.txt | /rss.xml |
public, max-age=300 |
max-age=3600, stale-if-error=86400 |
Purger le tag html |
| Images et icônes sans hash | /favicon.ico |
public, max-age=86400 |
Respecter l'origine | Purger l'URL ou renommer |
| JSON modifié entre deux déploiements | /data/stats.json |
public, max-age=60 |
Respecter l'origine ou bypass | Purger l'URL |
| Page 404 | toute URL absente | no-store avec always |
Pas de cache | Rien |
| API, prévisualisations, admin | /api/ |
private, no-store |
Règle de bypass | Rien |
Les valeurs edge avec max-age vont dans Cloudflare-CDN-Cache-Control. Si vous mettez les réponses 404 en cache à l'edge, étiquetez-les html, sinon une page publiée plus tard reste masquée derrière un 404 en cache.
Checklist de déploiement
- Les noms d'assets contiennent un hash du contenu, et rien qui ait une URL stable n'est marqué
immutable. - Chaque bloc Nginx doté de
add_headerinclut les en-têtes de sécurité, etalwaysn'apparaît que sur les en-têtes de sécurité et les réponses d'erreur. - Le HTML envoie
no-cacheavecCloudflare-CDN-Cache-Control, sanss-maxageà côté destale-while-revalidate. gzip_typescouvre les types texte, et les réponses statiques ne portent aucunSet-Cookie.- Browser Cache TTL respecte les en-têtes existants, et la règle de bypass vient en dernier sans condition limitée à
GET. - La CI envoie les assets avant le HTML, conserve les anciens fichiers hachés, purge le tag
htmlet échoue sisuccessne vaut pas true. - Une page affiche
MISS, puisHITavecAge, puisUPDATINGaprès le TTL edge. - L'origine n'accepte que le trafic provenant de Cloudflare.