Pour un site WordPress de contenu, le cache de proxy est le levier de performance le plus rentable : une page servie par Nginx depuis le disque ne réveille ni PHP ni MySQL. Mais un cache est une copie, et une copie peut être fausse. Ce guide détaille la configuration que nous utilisons devant nos deux sites WordPress, les erreurs que nous avons corrigées, et la façon de vérifier qu'un cache dit la vérité.

Le principe

Nginx reçoit la requête, calcule une clé, et cherche une réponse enregistrée sous cette clé. S'il la trouve et qu'elle est encore valide, il la renvoie sans contacter WordPress. Sinon, il transmet la requête à WordPress, renvoie la réponse et l'enregistre au passage. Tout le travail de configuration consiste à répondre à deux questions : qu'est-ce qui ne doit jamais être mis en cache, et combien de temps le reste reste-t-il valable.

La clé de cache

proxy_cache_key "$scheme$request_method$host$request_uri";

Inclure $host est indispensable dès que plusieurs sites partagent une zone de cache, ce qui est le cas d'un multisite. Inclure $request_uri (et non $uri) conserve les paramètres de requête : deux pages de résultats différentes ne doivent pas partager la même entrée.

Ce qu'il ne faut jamais mettre en cache

Nous calculons une variable $no_cache que deux directives exploitent : proxy_cache_bypass (ne pas lire le cache) et proxy_no_cache (ne pas y écrire).

set $no_cache 0;
if ($http_cookie ~* "wordpress_logged_in|comment_author|wp-postpass") { set $no_cache 1; }
if ($http_authorization != "") { set $no_cache 1; }
if ($request_method = POST) { set $no_cache 1; }
if ($request_uri ~* "wp-admin|wp-login\.php|admin-ajax\.php|wp-json") { set $no_cache 1; }

proxy_cache_bypass $no_cache;
proxy_no_cache $no_cache;

Deux directives complètent le dispositif : proxy_ignore_headers Cache-Control Expires Set-Cookie, car WordPress envoie des en-têtes qui empêcheraient toute mise en cache, et proxy_hide_header Set-Cookie, pour qu'une réponse cachée ne distribue jamais un cookie à des inconnus.

Choisir les durées

La durée de validité n'est pas une question de performance, c'est une question de fraîcheur acceptable. Nous en utilisons plusieurs, chacune justifiée par le contenu qu'elle protège :

Le cas des sitemaps : une erreur que nous avons commise

Nos sitemaps de villes et de centres sont des fichiers statiques reconstruits chaque nuit. Ils héritaient de la règle générale de quinze jours : Google recevait donc un sitemap périmé pendant deux semaines après chaque reconstruction. La correction consiste à leur donner une location dédiée :

location ^~ /wp-content/uploads/sites/2/sitemaps-ct/ {
    proxy_cache CACHE_MCT;
    proxy_cache_valid 200 1h;
    proxy_cache_revalidate on;
    proxy_pass http://localhost:8084;
}

Une heure de validité, puis une revalidation conditionnelle : Nginx demande au serveur si le fichier a changé depuis sa copie (If-Modified-Since) et ne le retélécharge que si c'est le cas. Le coût est minime et la fraîcheur garantie à l'heure près. Le principe se généralise : tout contenu régénéré à heure fixe doit avoir une durée de cache inférieure à son intervalle de régénération. Voir aussi notre guide des sitemaps XML.

Servir une copie ancienne plutôt qu'une erreur

proxy_cache_use_stale error timeout updating invalid_header
                      http_500 http_502 http_503 http_504;

Si WordPress ne répond plus, Nginx sert la dernière copie connue au lieu d'une page d'erreur. Pour un site de contenu, c'est un filet de sécurité précieux : une panne de PHP ou de base de données de quelques minutes devient invisible pour les visiteurs et pour les robots. L'option updating évite en outre que cent requêtes simultanées sur une entrée expirée déclenchent cent reconstructions.

Le piège : un cache qui ment

Voici l'incident qui nous a le plus appris. Nous avions déployé un durcissement : l'URL /wp-json/wp/v2/users, qui expose par défaut l'identifiant des auteurs — dont celui de l'administrateur — devait désormais répondre 404. Le code était en place et fonctionnait. Pourtant, sur l'un des deux sites, l'URL répondait toujours 200.

La raison : ce site n'excluait pas wp-json de son cache, contrairement à l'autre. Nginx servait une réponse enregistrée avant le déploiement. Seule une requête avec un paramètre aléatoire, qui produit une clé de cache inédite, révélait le vrai comportement :

curl -s -o /dev/null -w '%{http_code}\n' "https://exemple.fr/wp-json/wp/v2/users"
curl -s -o /dev/null -w '%{http_code}\n' "https://exemple.fr/wp-json/wp/v2/users?nocache=$RANDOM"

La leçon dépasse ce cas : derrière un cache, une vérification qui n'interroge que l'URL canonique peut se tromper dans les deux sens. Elle peut valider un déploiement qui n'a pas pris (le cache sert l'ancienne version correcte), ou signaler un défaut alors que le code est bon (le cache sert l'ancienne version fautive). Notre script de vérification d'après-déploiement fait désormais systématiquement les deux mesures et affiche les deux résultats.

Rendre le cache observable

add_header X-Cache-Status $upstream_cache_status always;
add_header X-No-Cache $no_cache always;

Ces deux en-têtes indiquent pour chaque réponse si elle vient du cache (HIT), si elle a été reconstruite (MISS, EXPIRED), servie périmée (STALE), ou si une règle d'exclusion s'est appliquée. Un simple curl -I suffit alors à comprendre ce qui se passe, sans accès au serveur.

Attention à l'héritage de add_header

Une particularité de Nginx a des conséquences de sécurité : une directive add_header placée dans une location annule toutes celles héritées du bloc server. Ajouter un en-tête de cache dans le bloc des fichiers statiques peut ainsi supprimer silencieusement vos en-têtes HSTS ou X-Frame-Options sur ces fichiers. Pour les ressources statiques, nous utilisons donc expires 30d, qui émet Cache-Control et Expires sans passer par add_header et sans rien annuler.

Les ressources statiques

Sur l'un de nos sites, aucune directive de cache navigateur n'était émise : chaque page de ville chargeait 22 scripts et 15 feuilles de style, et le navigateur les revalidait tous à chaque visite. Trente jours de cache navigateur règlent la question. Nous n'utilisons pas l'option immutable : WordPress versionne ses ressources par un paramètre ?ver=, mais un fichier réécrit sans changement d'URL doit pouvoir être repris dans un délai raisonnable.

Liste de contrôle

  1. Clé de cache incluant l'hôte et l'URI complète.
  2. Exclusions : cookies de session, Authorization, POST, administration, connexion, admin-ajax, API REST.
  3. Set-Cookie masqué sur les réponses cachées.
  4. Durées justifiées par la fraîcheur acceptable de chaque type de contenu, 404 courts.
  5. Contenus régénérés à heure fixe : durée inférieure à l'intervalle, avec revalidation.
  6. proxy_cache_use_stale pour absorber les pannes courtes.
  7. En-têtes de diagnostic, et vérification avec et sans contournement du cache après chaque déploiement.

Pour mesurer l'effet côté visiteurs, voir notre guide sur les Core Web Vitals sous WordPress.