Si vous avez déjà vu un TTFB à 800–1500 ms sur une page WordPress « simple » alors que le serveur est à 10% de CPU, vous avez probablement un site qui recalcule trop souvent la même réponse PHP. La mise en place d’un full page cache côté serveur (Nginx FastCGI cache ou Varnish) règle ce problème à la racine, sans plugin, à condition d’être strict sur les règles de contournement (cookies, admin, preview, pages personnalisées) et sur la purge.
Le problème de performance
Le symptôme typique : le HTML est identique pour 95% des visiteurs, mais WordPress 6.9.4 exécute quand même PHP + bootstrap + requêtes MySQL + hooks de thème/page builder à chaque hit. Résultat : TTFB élevé, Core Web Vitals pénalisés (surtout LCP sur mobile), et un serveur qui sature « en pics » dès qu’un article est partagé.
En pratique, j’ai souvent vu ce cas sur des sites avec Elementor ou Divi 5 : même si la page est statique, la génération passe par beaucoup de hooks, de shortcodes, et parfois des requêtes de méta (accompagnées d’autoloads d’options). Sans cache page, vous payez ce coût à chaque requête.
À la fin, vous saurez :
- activer un cache page Nginx FastCGI ou Varnish sans plugin et sans casser l’admin, les previews, ni les pages dynamiques ;
- poser des règles de bypass basées sur cookies/URL ;
- mettre en place une purge sélective via webhook sécurisé (WordPress → serveur) ;
- mesurer avant/après avec des outils reproductibles (curl, logs, WP-CLI, slow log).
Résumé rapide
- FastCGI cache (Nginx) est souvent le meilleur « 80/20 » : simple, très rapide, peu de moving parts.
- Varnish est excellent si vous voulez un reverse proxy dédié, du cache multi-backends, ou des règles de cache plus expressives (VCL).
- Sans plugin, vous devez gérer purge et bypass proprement : admin, preview, REST non cacheable, cookies de login/comment, e-commerce.
- Ne cachez jamais les pages pour utilisateurs connectés ; ne cachez pas wp-admin ; ne cachez pas les previews.
- Mesurez avec
curl -I(HIT/MISS), logs Nginx/Varnish, et timings côté WordPress (hookshutdown).
Diagnostic avec du code
1) Mesurer le temps WordPress (sans dépendre d’un plugin)
Ajoutez un mini-mu-plugin pour tracer un timing fiable côté PHP. Je le mets en MU plugin pour éviter « je l’ai collé dans functions.php et le thème a changé ».
<?php
/**
* Plugin Name: MU Perf Markers
* Description: Marqueurs de performance (WordPress 6.9.4+, PHP 8.1+)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'muplugins_loaded', function () {
// Début "réel" après chargement MU.
if ( ! defined( 'BPCAB_T0' ) ) {
define( 'BPCAB_T0', hrtime( true ) );
}
}, 1 );
add_action( 'shutdown', function () {
// Évite de polluer l'admin.
if ( is_admin() ) {
return;
}
$t0 = defined( 'BPCAB_T0' ) ? BPCAB_T0 : ( hrtime( true ) );
$dt_ms = ( hrtime( true ) - $t0 ) / 1e6;
// Indique si WP pense être "cacheable" (signal interne via constante/env).
$cache_hint = isset( $_SERVER['HTTP_X_CACHE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_CACHE'] ) ) : 'n/a';
// Ajout d'un header pour debug (à retirer quand c'est stable).
if ( ! headers_sent() ) {
header( 'X-WP-Gen-MS: ' . number_format( $dt_ms, 2, '.', '' ) );
header( 'X-WP-Cache-Hint: ' . $cache_hint );
}
}, 9999 );
Créez le fichier : wp-content/mu-plugins/mu-perf-markers.php. Erreur fréquente : oublier le dossier mu-plugins (WordPress ne le crée pas).
2) Activer les logs utiles (sans noyer la prod)
Dans wp-config.php, activez des logs propres. Sur prod, évitez WP_DEBUG_DISPLAY.
<?php
// wp-config.php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
// Log des requêtes (utile ponctuellement, coûteux si laissé activé).
define( 'SAVEQUERIES', true );
Piège réel : laisser SAVEQUERIES activé pendant des jours. Ça augmente la mémoire et peut fausser vos mesures. Activez-le pour un test court.
3) Slow query log MySQL/MariaDB (quand le TTFB grimpe)
Si vous avez accès à la conf DB, activez temporairement le slow log (exemple MySQL). Adaptez selon votre distribution.
# Exemple (à adapter) : vérifier la config active
mysql -e "SHOW VARIABLES LIKE 'slow_query_log%';"
mysql -e "SHOW VARIABLES LIKE 'long_query_time';"
# Activation temporaire (session globale)
mysql -e "SET GLOBAL slow_query_log = 'ON';"
mysql -e "SET GLOBAL long_query_time = 0.2;"
Ensuite, corrélez les URLs lentes avec les requêtes. Sur WordPress, les lenteurs viennent souvent de postmeta non indexée correctement ou d’options autoload surdimensionnées.
4) WP-CLI : mesurer et repérer les autoloads
Deux commandes qui trouvent vite des bombes de perf.
# Top options autoload (souvent responsable de TTFB élevé)
wp option list --autoload=on --fields=option_name,size --format=table | head -n 40
# Vérifier le cron (trop de hooks = latence)
wp cron event list --fields=hook,next_run --format=table | head -n 50
5) Vérifier le cache en HTTP (HIT/MISS) et le TTFB
Avant d’installer quoi que ce soit, mesurez. Puis vous refaites exactement les mêmes commandes après.
# Mesure simple du TTFB (time_starttransfer)
curl -o /dev/null -s -w "TTFB=%{time_starttransfer}s TOTAL=%{time_total}sn" https://example.com/
# Inspecter les headers (vous ajouterez X-Cache ensuite)
curl -I https://example.com/
Étape 1 : FastCGI Cache Nginx (micro-config WordPress-aware)
FastCGI cache est généralement le plus simple : Nginx met en cache la réponse PHP (HTML) et sert ensuite un fichier cache sans exécuter PHP. Sur un blog, ça peut faire passer un TTFB de ~600 ms à ~30–80 ms, surtout sous charge.
Le « AVANT » typique (lent sous charge)
Configuration Nginx qui passe tout à PHP, sans cache :
# Extrait simplifié (AVANT)
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ .php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
Le « APRÈS » : zone de cache + règles de bypass
Vous ajoutez une zone FastCGI cache au niveau http, puis vous activez fastcgi_cache sur le bloc PHP. Le point clé : définir quand ne pas cacher (admin, login, preview, cookies de session).
# /etc/nginx/nginx.conf (dans le bloc http {})
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=WORDPRESS:200m inactive=60m max_size=5g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# Optionnel : éviter le "cache stampede" (plusieurs requêtes régénèrent en même temps)
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 10s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_503;
fastcgi_cache_background_update on;
Ensuite, dans votre server :
# /etc/nginx/sites-available/example.conf (extrait)
server {
server_name example.com;
root /var/www/example/public;
# Variable de bypass (0 = cache autorisé, 1 = bypass)
set $skip_cache 0;
# Ne pas cacher les requêtes non-GET/HEAD
if ($request_method !~ ^(GET|HEAD)$) {
set $skip_cache 1;
}
# Ne pas cacher wp-admin, login, preview, xmlrpc
if ($request_uri ~* "/wp-admin/|/wp-login.php|/wp-json/|/xmlrpc.php|preview=true") {
set $skip_cache 1;
}
# Ne pas cacher si l'utilisateur est connecté ou a commenté (cookies WordPress)
if ($http_cookie ~* "wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|woocommerce_cart_hash") {
set $skip_cache 1;
}
# Ne pas cacher si query string "nocache" (utile pour debug)
if ($arg_nocache = "1") {
set $skip_cache 1;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ .php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Cache FastCGI
fastcgi_cache WORDPRESS;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# TTL : adaptez selon votre fréquence de publication
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_valid any 1m;
# Headers de debug (à garder au début)
add_header X-Cache $upstream_cache_status always;
add_header X-Cache-Skip $skip_cache always;
# Ne pas mettre en cache Set-Cookie (sinon vous figez des sessions)
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
}
# Ne cachez jamais les assets via FastCGI (ils ne passent pas par PHP)
location ~* .(css|js|jpg|jpeg|png|gif|svg|webp|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
}
Pourquoi c’est plus rapide (et ce que ça change vraiment)
Avec cache HIT : Nginx sert une réponse depuis /var/cache/nginx/fastcgi. PHP-FPM n’est pas appelé. MySQL n’est pas appelé. Vous supprimez la latence réseau DB, la compilation/chargement, et la plupart des IO.
Mesure typique (blog, thème builder, 1–3 plugins lourds) :
- Avant : X-WP-Gen-MS ~ 250–900 ms, TTFB ~ 400–1200 ms.
- Après (HIT) : X-WP-Gen-MS absent (PHP non exécuté), TTFB ~ 20–80 ms.
- Après (MISS) : similaire à avant, mais vous « payez » une fois par TTL/purge.
Étape 2 : Purge sélective (sans plugin) via webhook sécurisé
Sans purge, vous finissez avec un TTL très court (donc peu de gains) ou des pages qui restent vieilles trop longtemps. Le vrai besoin : purger quand un contenu change (post, page, menu, widget, thème builder).
Avec FastCGI cache, il n’y a pas de « PURGE » standard. La méthode robuste : exposer un endpoint interne (Nginx) qui supprime des fichiers cache via une clé, et déclencher ça depuis WordPress via un webhook signé.
1) Endpoint Nginx de purge (restreint + clé)
Exemple : une URL interne /__purge qui exécute fastcgi_cache_purge si vous avez le module (sur certaines distros), ou qui délègue à un script shell via ngx_http_lua_module. Comme les modules varient, je vous donne une approche portable : purger par ban de clé via cache_key est difficile sans module. En pratique, sur Debian/Ubuntu, je vois souvent Nginx compilé sans purge. Je préfère donc une purge « safe » par rotation de clé (versioning) ou par suppression ciblée si vous contrôlez le chemin.
Approche fiable et simple : versionner la clé via une variable (cache-buster) stockée dans un fichier et incluse par Nginx. Quand WordPress publie, vous incrémentez la version, ce qui invalide l’ancien cache immédiatement (sans supprimer les fichiers).
# /etc/nginx/conf.d/wp-cache-buster.conf
set $wp_cache_buster "1";
Et vous modifiez la clé :
# nginx.conf
fastcgi_cache_key "$scheme$request_method$host$request_uri|$wp_cache_buster";
Ensuite, vous exposez un endpoint interne qui met à jour ce fichier. Exemple via un script très simple appelé par cgi n’est pas recommandé. Le plus propre : un petit service systemd (HTTP local) ou un script appelé par SSH depuis WordPress. Pour rester « sans plugin » mais sécurisé, je recommande un webhook HTTP local sur loopback géré par un mini service (Caddy/Go) — mais ça sort du scope. Donc je vous donne une option pragmatique : WordPress appelle un endpoint Nginx qui proxy vers un service local (ex: 127.0.0.1:9100) qui ne répond qu’avec une clé HMAC.
2) MU-plugin : webhook de purge (HMAC + nonces)
WordPress déclenche une purge quand un post est publié/mis à jour. Attention : certains builders (Elementor/Divi/Avada) modifient du contenu via des post types et des metas ; vous devez aussi purger sur save_post pour ces types, et sur customize_save_after si vous utilisez le Customizer.
<?php
/**
* Plugin Name: MU Cache Purger (Webhook)
* Description: Purge cache page côté reverse proxy via webhook signé (sans plugin).
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class BPCAB_Cache_Purger {
private string $endpoint;
private string $secret;
public function __construct( string $endpoint, string $secret ) {
$this->endpoint = $endpoint;
$this->secret = $secret;
}
public function hooks(): void {
add_action( 'transition_post_status', [ $this, 'on_transition_post_status' ], 10, 3 );
add_action( 'deleted_post', [ $this, 'on_deleted_post' ], 10, 1 );
// Menus / widgets peuvent impacter le rendu global.
add_action( 'wp_update_nav_menu', [ $this, 'purge_all' ], 10, 1 );
add_action( 'customize_save_after', [ $this, 'purge_all' ], 10, 1 );
// Elementor/Divi/Avada : beaucoup de changements passent par save_post sur des post types.
add_action( 'save_post', [ $this, 'on_save_post' ], 20, 3 );
}
public function on_transition_post_status( string $new_status, string $old_status, WP_Post $post ): void {
if ( wp_is_post_revision( $post ) ) {
return;
}
// Purge quand le contenu devient public ou est mis à jour en public.
if ( 'publish' === $new_status ) {
$this->purge_post( $post->ID );
}
}
public function on_deleted_post( int $post_id ): void {
$this->purge_post( $post_id );
}
public function on_save_post( int $post_id, WP_Post $post, bool $update ): void {
if ( wp_is_post_revision( $post_id ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Évite de purger sur chaque brouillon.
if ( 'publish' !== $post->post_status ) {
return;
}
// Post types fréquents des builders (à adapter).
$builder_types = [
'page',
'post',
'elementor_library',
'et_pb_layout', // Divi (souvent)
'fusion_template', // Avada (souvent)
];
if ( in_array( $post->post_type, $builder_types, true ) ) {
$this->purge_post( $post_id );
}
}
public function purge_post( int $post_id ): void {
$url = get_permalink( $post_id );
if ( ! $url ) {
// Si on ne peut pas résoudre l'URL, on purge global.
$this->purge_all();
return;
}
// Purge de la page + home + archives basiques (approche prudente).
$targets = array_filter( array_unique( [
home_url( '/' ),
$url,
get_post_type_archive_link( get_post_type( $post_id ) ) ?: '',
] ) );
foreach ( $targets as $target ) {
$this->send_purge( $target );
}
}
public function purge_all( $ignored = null ): void {
// Rotation globale de clé côté serveur.
$this->send_purge( 'ALL' );
}
private function send_purge( string $target ): void {
$ts = time();
$payload = wp_json_encode( [
'target' => $target,
'ts' => $ts,
'site' => home_url(),
], JSON_UNESCAPED_SLASHES );
if ( ! is_string( $payload ) ) {
return;
}
$sig = hash_hmac( 'sha256', $payload, $this->secret );
$args = [
'timeout' => 2,
'headers' => [
'Content-Type' => 'application/json',
'X-WP-Purge-Signature' => $sig,
],
'body' => $payload,
];
// Erreur fréquente : utiliser wp_remote_get() et se faire bloquer par des règles WAF.
// POST JSON est plus explicite et plus facile à filtrer côté serveur.
$res = wp_remote_post( $this->endpoint, $args );
// Optionnel : log minimal si échec.
if ( is_wp_error( $res ) ) {
error_log( '[purge] WP_Error: ' . $res->get_error_message() );
return;
}
$code = (int) wp_remote_retrieve_response_code( $res );
if ( $code < 200 || $code >= 300 ) {
error_log( '[purge] HTTP ' . $code . ' body=' . wp_remote_retrieve_body( $res ) );
}
}
}
// Config via constantes (wp-config.php) pour éviter de stocker un secret en DB.
$endpoint = defined( 'BPCAB_PURGE_ENDPOINT' ) ? BPCAB_PURGE_ENDPOINT : '';
$secret = defined( 'BPCAB_PURGE_SECRET' ) ? BPCAB_PURGE_SECRET : '';
if ( $endpoint && $secret ) {
( new BPCAB_Cache_Purger( $endpoint, $secret ) )->hooks();
}
Dans wp-config.php :
<?php
define( 'BPCAB_PURGE_ENDPOINT', 'http://127.0.0.1:9100/purge' );
define( 'BPCAB_PURGE_SECRET', 'changez-moi-par-une-cle-longue-et-aleatoire' );
Risque sécurité : un endpoint de purge exposé publiquement devient une arme de déni de cache. Gardez-le en loopback (127.0.0.1) ou derrière un firewall, et signez les requêtes (HMAC) comme ci-dessus.
Étape 3 : Varnish devant WordPress (VCL orienté WordPress)
Varnish est un reverse proxy HTTP : il cache au niveau HTTP, avant Nginx/Apache/PHP. C’est souvent la meilleure option quand vous avez plusieurs backends, besoin de grace, ou des règles fines (ban, hit-for-pass). Sur un blog, Varnish + Nginx (en backend) est une combinaison très solide.
VCL minimal (cache GET/HEAD, bypass WordPress)
Exemple VCL (Varnish 7.x). Adaptez les backends et ports.
vcl 4.1;
backend default {
.host = "127.0.0.1";
.port = "8080"; # Nginx backend
.connect_timeout = 1s;
.first_byte_timeout = 30s;
.between_bytes_timeout = 30s;
}
sub vcl_recv {
# Ne cachez pas autre chose que GET/HEAD
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Bypass admin/login/REST
if (req.url ~ "^/wp-admin/" ||
req.url ~ "^/wp-login.php" ||
req.url ~ "^/wp-json/" ||
req.url ~ "^/xmlrpc.php") {
return (pass);
}
# Query string nocache
if (req.url ~ "(?|&)nocache=1(&|$)") {
return (pass);
}
# Cookies WordPress : bypass si connecté / post password / comment
if (req.http.Cookie ~ "wordpress_logged_in_" ||
req.http.Cookie ~ "wp-postpass_" ||
req.http.Cookie ~ "comment_author_" ||
req.http.Cookie ~ "woocommerce_items_in_cart" ||
req.http.Cookie ~ "woocommerce_cart_hash") {
return (pass);
}
# Normaliser certains paramètres marketing (évite de fragmenter le cache)
if (req.url ~ "utm_" || req.url ~ "fbclid=" || req.url ~ "gclid=") {
set req.url = regsuball(req.url, "(utm_[^=]+|fbclid|gclid)=[^&]+&?", "");
set req.url = regsub(req.url, "[?&]$", "");
}
return (hash);
}
sub vcl_backend_response {
# Respecter les réponses non cacheables
if (beresp.http.Set-Cookie) {
set beresp.ttl = 0s;
set beresp.uncacheable = true;
return (deliver);
}
# TTL par défaut pour HTML
if (beresp.http.Content-Type ~ "text/html") {
set beresp.ttl = 10m;
set beresp.grace = 30m; # Sert du stale pendant régénération
}
# Assets : TTL long (si votre backend les sert)
if (bereq.url ~ ".(css|js|jpg|jpeg|png|gif|svg|webp|ico|woff2?)($|?)") {
set beresp.ttl = 30d;
}
return (deliver);
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
}
Purge Varnish (BAN) via webhook signé
Varnish supporte BAN (invalidation par expression). Vous pouvez exposer une URL de purge sur Nginx (ou un petit service) qui exécute varnishadm ban. Exemple de commande :
# Purger une URL exacte
varnishadm ban 'req.http.host == "example.com" && req.url == "/mon-article/"'
# Purge globale (à éviter trop souvent)
varnishadm ban 'req.http.host == "example.com"'
Je préfère BAN à PURGE ici, car BAN est asynchrone et ne bloque pas le trafic. Edge case : BAN n’efface pas immédiatement les objets, il les marque invalides au prochain accès.
Étape 4 : Bypass propres (cookies, preview, e-commerce, page builders)
La majorité des « mon cache casse le site » viennent d’un bypass incomplet. Voici les cas que je traite systématiquement.
1) Preview / révisions / personnalisations
- Preview :
?preview=trueet cookies associés → pass. - Customizer : endpoints et cookies → pass.
- Révisions : pas directement servies au public, mais les previews oui.
2) Utilisateurs connectés
Bypass sur wordpress_logged_in_ est non négociable. Erreur fréquente : tester en étant connecté, voir « MISS », conclure que le cache ne marche pas. Testez en navigation privée ou via curl sans cookies.
3) WooCommerce / EDD
Si vous avez un blog + boutique, le full page cache doit ignorer panier/checkout/mon compte, et bypass sur cookies de panier. Même si votre sujet est « blog », je le cite car beaucoup de sites hybrides se font piéger.
4) Divi 5, Elementor, Avada : ce qui change côté cache
- Elementor : templates
elementor_librarypeuvent impacter plusieurs pages. Purger seulement l’URL de la page éditée est insuffisant : je purge souvent home + page + archives, ou je fais une rotation globale lors d’un changement de template. - Divi 5 : les layouts globaux (
et_pb_layout) peuvent être injectés partout. Même stratégie : purge large ou rotation. - Avada : templates type
fusion_templateidem.
Si vous voulez être plus fin, il faut construire un graphe de dépendances (quels templates affectent quelles URLs). C’est faisable, mais ça dépasse le « sans plugin » raisonnable.
Configuration serveur
PHP-FPM : éviter que le cache masque un backend instable
Le cache réduit la charge, mais si PHP-FPM est mal dimensionné, vous aurez des 502 sur les MISS. Ajustez au minimum :
; /etc/php/8.3/fpm/pool.d/www.conf (exemple)
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 4
pm.max_spare_servers = 8
pm.max_requests = 500
; Slowlog utile quand un MISS est lent
request_slowlog_timeout = 3s
slowlog = /var/log/php8.3-fpm/www-slow.log
Vous êtes en PHP 8.1+ minimum ; en 2026, beaucoup de serveurs tournent en 8.3/8.4. Adaptez les chemins.
Nginx : logs de cache (indispensable au début)
Ajoutez un format de log qui inclut HIT/MISS et le temps upstream :
# nginx.conf (http {})
log_format cachelog '$remote_addr - $host "$request" $status '
'cache=$upstream_cache_status skip=$sent_http_x_cache_skip '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/access-cache.log cachelog;
.htaccess ?
Si vous êtes sur Apache, le sujet change (mod_cache, mod_proxy_fcgi, varnish devant). Ici on vise Nginx FastCGI cache et Varnish. Ne collez pas des règles .htaccess « cache » trouvées sur des vieux tutos : elles ne feront rien sur Nginx, et certaines cassent les headers.
WordPress : forcer les bons headers (optionnel)
Je ne recommande pas de surcharger agressivement Cache-Control côté WordPress si vous avez déjà Nginx/Varnish. Par contre, vous pouvez éviter que certaines pages soient accidentellement cacheables.
<?php
// MU-plugin : empêcher le cache sur certaines routes sensibles
add_action( 'send_headers', function () {
// Exemple : page "mon-compte" ou toute route custom sensible
if ( is_page( 'mon-compte' ) || is_page( 'checkout' ) ) {
nocache_headers();
}
}, 20 );
Vérification des résultats
1) Vérifier HIT/MISS et TTFB
Après déploiement, faites 3 hits :
# 1) Premier hit : MISS attendu (génération)
curl -I https://example.com/ | egrep -i "x-cache|x-wp-gen-ms|server|cache-control"
# 2) Deuxième hit : HIT attendu
curl -I https://example.com/ | egrep -i "x-cache|x-wp-gen-ms|server|cache-control"
# 3) Mesure TTFB
curl -o /dev/null -s -w "TTFB=%{time_starttransfer}s TOTAL=%{time_total}sn" https://example.com/
Interprétation :
- FastCGI cache :
X-Cache: HITetX-WP-Gen-MSabsent (ou inchangé si un proxy réinjecte). - Varnish :
X-Cache: HITsur le 2e hit. - Si vous voyez toujours MISS : vous avez un cookie, une règle de bypass trop large, ou un
Set-Cookierenvoyé par le backend.
2) Vérifier en conditions « anonymes »
Testez sans cookies :
curl -I -H "Cookie:" https://example.com/ | egrep -i "x-cache|set-cookie"
3) Vérifier les logs slow (MISS) et la stabilité
Quand le cache est en place, les lenteurs restantes sont sur les MISS (purge, expiration). Regardez :
# PHP-FPM slow log
tail -n 50 /var/log/php8.3-fpm/www-slow.log
# Nginx cache log
tail -n 50 /var/log/nginx/access-cache.log
Si les performances ne s’améliorent pas
Quand « ça ne change rien », la cause est presque toujours dans une de ces catégories :
1) Vous testez connecté (donc bypass)
Je le répète car c’est le piège numéro 1. Les cookies wordpress_logged_in_ déclenchent un pass. Testez en navigation privée ou avec curl sans cookies.
2) Un plugin renvoie un Set-Cookie sur toutes les pages
Certains plugins de tracking/A-B test posent un cookie dès la home. Résultat : Varnish/Nginx refuse de cache. Vérifiez :
curl -I https://example.com/ | egrep -i "set-cookie|x-cache"
Solution : configurer ce plugin pour ne pas setter systématiquement, ou ignorer le cookie côté cache (dangereux), ou isoler ces pages.
3) Votre règle de bypass est trop large
Exemple courant : if ($request_uri ~* "wp") (vu en prod…). Ça bypass quasiment tout. Soyez chirurgical sur les patterns.
4) Le goulot est ailleurs : DNS/CDN/TLS
Si le TTFB baisse mais le total time reste haut, regardez la résolution DNS, le handshake TLS, ou une latence réseau. Utilisez :
curl -o /dev/null -s -w "DNS=%{time_namelookup} TLS=%{time_appconnect} TTFB=%{time_starttransfer} TOTAL=%{time_total}n" https://example.com/
5) Le HTML est cacheable, mais vos assets sont le vrai problème
Le full page cache ne corrige pas un JS de 900 KB bloquant. Si LCP reste mauvais, vous devez traiter defer, preload, lazy loading, et la réduction JS/CSS. Ici on reste volontairement sur le cache page, mais ne confondez pas TTFB et LCP.
Pièges et erreurs courantes
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Cache toujours en MISS | Test en étant connecté (cookie wordpress_logged_in_) |
curl -I + inspecter Cookie |
Tester sans cookies, ajuster bypass uniquement pour connectés |
| Pages « figées » après mise à jour | Pas de purge / TTL trop long | Mettre à jour un post, recharger en anonyme | Mettre en place webhook de purge (rotation de clé ou BAN Varnish) |
| Admin cassé / CSS manquants | Règles de cache appliquées à /wp-admin/ ou mauvais try_files |
Logs Nginx + vérifier locations | Bypass strict sur /wp-admin/, servir assets en direct |
| 502 sur pics de trafic | PHP-FPM sous-dimensionné sur les MISS (cache stampede) | Logs PHP-FPM + fastcgi_cache_lock absent |
Activer fastcgi_cache_lock, dimensionner FPM, activer stale/background update |
| Cache OK sur desktop, pas sur mobile | Variation par User-Agent (mauvaise config) ou thème mobile séparé | Comparer curl -A desktop vs mobile |
Éviter de hasher sur UA ; gérer responsive côté CSS, pas côté serveur |
| Le cache fuit des pages personnalisées | Cookies de segmentation ignorés ou pages dynamiques cachées | Vérifier HTML selon segments | Bypass sur cookies pertinents, ou ESI/edge logic (Varnish avancé) |
| Snippet cassé / écran blanc | Code collé dans le mauvais fichier (functions.php) + erreur de syntaxe | wp-content/debug.log, logs PHP |
Utiliser MU-plugin, valider syntaxe, déployer d’abord en staging |
| Rien ne change après modif Nginx | Vous avez oublié nginx -t / reload, ou édité le mauvais vhost |
nginx -T, vérifier la conf chargée |
nginx -t && systemctl reload nginx |
Erreurs que je vois souvent en mise en place
- Copier une conf Nginx trouvée sur un vieux blog (pré-PHP 8.x) avec des chemins de socket faux, puis conclure que « FastCGI cache ne marche pas ».
- Oublier un point-virgule dans un
setNginx, et Nginx refuse de reload (d’où l’intérêt denginx -t). - Mettre la purge sur une URL publique sans signature : un bot peut forcer des MISS permanents.
- Tester sur production sans plan de rollback : faites au minimum un vhost staging, ou un canary sur une URL.
Conseils de maintenance
- Gardez les headers de debug (
X-Cache,X-Cache-Skip) quelques jours, puis retirez-les ou limitez-les à votre IP pour éviter d’exposer trop d’infos. - Surveillez le ratio HIT : si vous êtes à 10–20% HIT sur un blog, quelque chose bypass trop souvent (cookies, query strings, Set-Cookie).
- Planifiez la purge : menus, widgets, templates builder. Les hooks WordPress fournis plus haut couvrent déjà une partie des cas réels.
- Documentez vos exceptions (pages dynamiques) dans la conf Nginx/VCL, pas « dans la tête ».
- Déployez avec un switch : une variable
$skip_cacheforçable (ou une ACL) permet de désactiver le cache en incident sans tout casser.
Ressources
- developer.wordpress.org – nocache_headers()
- developer.wordpress.org – wp-config.php et constantes de debug
- wordpress.org – Must-Use Plugins (mu-plugins)
- developer.wordpress.org – wp_remote_post()
- php.net – hash_hmac()
- GitHub – WordPress core mirror (wordpress-develop)
- WordPress Trac – tickets core (référence)
FAQ
FastCGI cache ou Varnish : lequel choisir pour un blog ?
Si vous êtes déjà sur Nginx + PHP-FPM, FastCGI cache est le choix le plus direct. Varnish devient intéressant si vous voulez un reverse proxy dédié, du grace avancé, des BAN efficaces, ou une architecture multi-backends.
Est-ce vraiment « sans plugin » si j’ajoute un MU-plugin ?
Oui : vous n’installez pas de plugin de cache qui injecte des règles et une UI. Vous ajoutez un petit code d’intégration (purge/headers) versionné, auditable, et déployable comme du code applicatif.
Pourquoi je ne vois pas de HIT quand je teste ?
Dans 80% des cas : vous êtes connecté, ou un Set-Cookie est renvoyé. Testez avec curl -I -H "Cookie:" et cherchez Set-Cookie.
Dois-je inclure le User-Agent dans la clé de cache ?
Non, sauf cas très particulier. Ça fragmente le cache et détruit le ratio HIT. Si votre thème sert un HTML différent mobile/desktop côté serveur, c’est généralement une dette technique à résorber.
Comment gérer les pages avec contenu personnalisé (géolocalisation, A/B test) ?
Soit vous bypass pour ces pages (simple, mais moins de cache), soit vous mettez en place une stratégie edge (Varnish + cookies/headers de variation), soit vous rendez le HTML cacheable et vous personnalisez via JS côté client (attention SEO).
Le cache page remplace-t-il un object cache (Redis/Memcached) ?
Non. Le cache page évite d’exécuter WordPress sur un HIT. L’object cache accélère les MISS (requêtes DB, options, transients). Les deux se complètent très bien.
Comment purger « seulement » la page modifiée avec FastCGI cache ?
Sans module de purge Nginx, ce n’est pas propre. La rotation de clé (cache-buster) est fiable, mais purge globalement. Si vous voulez une purge fine, Varnish (BAN) est souvent plus adapté, ou une build Nginx avec module purge.
Pourquoi mon builder (Elementor/Divi/Avada) nécessite une purge plus large ?
Parce que les templates globaux et sections réutilisées changent le rendu de nombreuses URLs. Sans graphe de dépendances, la purge fine rate des pages. C’est pour ça que je propose une purge prudente (home + page + archive) ou une rotation globale lors de changements de templates.
Que dois-je monitorer en continu ?
TTFB (p95), ratio HIT/MISS, erreurs 5xx, temps upstream PHP, et taille du cache disque. Un cache qui grossit sans limite finit par évincer en boucle (thrash) et perd son intérêt.
Est-ce compatible avec WordPress 6.9.4 et PHP 8.1+ ?
Oui. Le code fourni utilise des APIs stables (wp_remote_post, hooks classiques) et des primitives PHP disponibles en 8.1+ (hash_hmac, hrtime).