Le besoin / Le problème serveur
Si vous avez déjà vu des pics CPU “inexplicables” sur PHP-FPM pendant une campagne, puis des timeouts 504 côté reverse proxy, vous avez déjà rencontré la limite d’un WordPress mono-serveur. Le problème vient rarement de WordPress “en soi” : c’est la combinaison PHP + I/O + sessions + uploads + cache qui sature un point unique.
L’objectif ici est concret : mettre WordPress 6.9.4 (PHP 8.1+) derrière HAProxy, répartir la charge sur plusieurs serveurs web, garder des connexions TLS propres, des health checks qui détectent vraiment un nœud malade, et éviter les pièges classiques (uploads non partagés, cache incohérent, stickiness mal réglée, base de données qui devient le nouveau goulot).
À la fin, vous saurez déployer un front HAProxy (TLS + HTTP/2), plusieurs nœuds Nginx+PHP-FPM, un stockage partagé des médias, Redis pour l’object cache, et une stratégie “drain” pour sortir un nœud sans casser des sessions ni des uploads en cours.
Concrètement, ce type d’architecture sert à absorber des pics sans “surprovisionner” un seul serveur. Mais elle ne pardonne pas les petites incohérences : un nœud avec un wp-config.php différent, une horloge en retard, un cache disque local qui traîne, et vous obtenez des bugs intermittents impossibles à reproduire en local. Dans mon expérience, c’est justement la répétabilité (déploiement, config, vérifications) qui fait la différence entre un cluster WordPress stable et un cluster “flaky”.
Résumé rapide
- HAProxy en frontal : terminaison TLS, routage HTTP, health checks applicatifs, et stickiness optionnelle basée sur cookie.
- Nœuds WordPress stateless : code identique partout, pas d’uploads locaux “uniques”, logs centralisés.
- Uploads partagés : NFS simple (rapide à mettre en place) ou stockage objet (plus robuste). Je montre NFS ici (avec points d’attention).
- Base de données : un primaire + (éventuellement) réplique(s). WordPress écrit beaucoup plus qu’on ne le pense (options, transients, sessions de plugins).
- Cache : FastCGI cache (prudence sur le cookie/auth) + Redis object cache. Invalidation maîtrisée, pas “cache tout et priez”.
- Exploitation : drain d’un backend, déploiement atomique (rsync/artefacts), staging, et commandes de vérification (curl, WP-CLI, logs).
Si vous devez prioriser : (1) uploads partagés, (2) salts identiques, (3) headers proxy corrects, (4) object cache Redis, (5) cache HTTP seulement quand vous maîtrisez les exclusions. J’ai vu trop de projets commencer par “FastCGI cache partout” et finir en incident de fuite de contenu.
Avant de commencer (prérequis)
Accès requis :
- SSH root ou sudo sur HAProxy et sur chaque nœud web.
- Accès MySQL/MariaDB (CLI) sur le serveur DB.
- WP-CLI sur au moins un nœud web (idéalement tous), version récente compatible WP 6.9.4.
- Accès DNS</strong (ou au minimum /etc/hosts) pour pointer le domaine vers HAProxy.
Versions minimales recommandées (avril 2026, WordPress 6.9.4) :
- PHP 8.1+ (8.2/8.3 souvent plus confortable). Voir PHP Supported Versions.
- Nginx stable, PHP-FPM, HAProxy 2.8+ (ou version distro équivalente), Redis 7+.
- MySQL 8.0+ ou MariaDB récent. (Évitez les versions fin de vie.)
Sauvegarde obligatoire avant toute bascule :
- Dump DB + sauvegarde
wp-content(plugins, thèmes, uploads). - Export de la config (Nginx, PHP-FPM, HAProxy).
# Sur le serveur DB (ou depuis un bastion avec accès)
# Remplacez DB_NAME, DB_USER. Utilisez un compte avec droits de lecture.
mysqldump --single-transaction --routines --triggers --events
-u DB_USER -p DB_NAME | gzip > /root/backup-db-$(date +%F).sql.gz
# Sur un nœud WordPress (code + contenus)
# --numeric-ids évite les surprises UID/GID si vous restaurez ailleurs.
tar --numeric-owner --numeric-ids -czf /root/backup-wpcontent-$(date +%F).tar.gz
/var/www/example.com/wp-content
Variantes utiles selon vos contraintes :
- Si la DB est grosse, préférez un dump compressé avec
pigz(multi-core) et stockez sur un volume séparé :mysqldump ... | pigz -p 4 > .... - Si vous avez des tables très volumineuses (logs, analytics), vous pouvez exclure temporairement certaines tables non critiques pour accélérer un test de bascule, puis réintégrer ensuite (attention à la cohérence applicative).
- Si votre hébergeur impose un proxy sortant, vérifiez que
certbotet les mises à jour APT fonctionnent depuis HAProxy.
Note d’expérience : j’ai souvent vu des migrations “HAProxy + multi-nœuds” échouer non pas sur HAProxy, mais sur un oubli de partage des uploads ou un cache agressif qui met en cache des pages admin. Gardez votre première itération simple, puis durcissez.
Étape 1 : Architecture cible et flux réseau (qui parle à qui)
Architecture de référence (simple et efficace) :
- 1x HAProxy public (IP publique, ports 80/443).
- 2x (ou plus) nœuds web privés : Nginx + PHP-FPM + WordPress (code identique).
- 1x DB (primaire), optionnellement 1x réplique.
- 1x Redis (object cache). Peut être sur la DB ou séparé.
- 1x NFS (ou stockage objet) pour
wp-content/uploads.
Flux HTTP :
- Client → HAProxy (TLS) → Nginx (HTTP) → PHP-FPM (socket) → DB/Redis/NFS.
Ce que ça change pour WordPress :
- Les nœuds doivent être stateless : ne stockez rien d’unique localement (uploads, cache disque “invalide”, sessions PHP sur disque si elles sont utilisées par un plugin).
- IP réelle : il faut forwarder
X-Forwarded-ForetX-Forwarded-Proto, et demander à WordPress de les interpréter correctement (sinon HTTPS détecté “faux”, cookies cassés, mixed content).
Edge cases que je prends en compte dès le design :
- Webhooks / IP allowlist : si un plugin de paiement ou un WAF fait une allowlist sur IP, vous devrez vous baser sur l’IP client réelle (et donc maîtriser la chaîne de proxy).
- WP-Cron : sur multi-nœuds, déclencher
wp-cron.phpvia trafic web peut créer des exécutions concurrentes. Je préfère un cron système sur un seul nœud (ou un job externe) et désactiver le déclenchement “par visite”. - Uploads chunkés (certains plugins/builder) : un upload peut être découpé en plusieurs requêtes. Si vous avez de la stickiness partielle, assurez-vous que ces requêtes finissent sur le même backend ou que le stockage temporaire est partagé.
Compatibilité page builders (Divi 5, Elementor, Avada) : ils génèrent beaucoup de requêtes AJAX/REST et manipulent des médias. Votre infra doit donc tenir :
- des POST lourds (édition, import/export de layouts),
- des uploads multiples,
- des endpoints REST (
/wp-json/) sensibles au cache.
Étape 2 : Installer HAProxy, terminer TLS et poser des health checks fiables
Sur Debian/Ubuntu, installez HAProxy et activez le service. Adaptez selon votre distribution.
sudo apt-get update
sudo apt-get install -y haproxy
sudo systemctl enable haproxy
sudo systemctl status haproxy --no-pager
Choix technique : je termine TLS sur HAProxy. C’est plus simple pour centraliser certificats, ciphers, HTTP/2, HSTS. Vos backends restent en HTTP privé (ou mTLS interne si vous êtes strict).
Pour les certificats, utilisez Let’s Encrypt sur HAProxy (certbot en mode “deploy hook”) ou un gestionnaire interne. HAProxy attend typiquement un fichier .pem concaténé (cert + clé). Exemple minimal :
# Exemple : concaténer fullchain + privkey pour HAProxy
sudo cat /etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
| sudo tee /etc/haproxy/certs/example.com.pem > /dev/null
sudo chmod 600 /etc/haproxy/certs/example.com.pem
sudo chown root:root /etc/haproxy/certs/example.com.pem
Edge cases TLS que je traite systématiquement :
- Renouvellement : après renouvellement Let’s Encrypt, HAProxy ne recharge pas toujours automatiquement selon votre setup. Ajoutez un hook qui fait un
systemctl reload haproxy(reload, pas restart) et surveillez les erreurs de parsing du PEM. - SNI multi-domaines : si vous avez plusieurs vhosts, HAProxy peut charger un répertoire de certs. Testez le bon certificat avec
openssl s_client -servername .... - HTTP/2 : certains clients/proxies d’entreprise ont des comportements bizarres. Gardez
alpn h2,http/1.1(comme ici) et vérifiez que vous ne cassez pas des intégrations.
Health checks : évitez le simple TCP check. Un nœud peut accepter TCP mais avoir PHP-FPM mort, ou une DB inaccessible. Je préfère un endpoint applicatif qui vérifie au minimum PHP + DB, sans dépendre d’un cache.
Créez un endpoint /lb-health.php déployé sur chaque nœud (dans la racine web), qui fait un ping DB léger. Attention : ce fichier doit être protégé (pas d’infos sensibles), et idéalement accessible uniquement depuis HAProxy (ACL réseau).
<?php
/**
* Health check simple pour HAProxy.
* - Vérifie que PHP tourne
* - Vérifie une connexion DB via WordPress (wpdb)
*
* Sécurité :
* - Ne renvoyez aucune info sensible
* - Restreignez l'accès par IP (firewall / Nginx allow/deny)
*/
define('SHORTINIT', true); // Démarrage WordPress minimal (plus rapide)
require __DIR__ . '/wp-load.php';
global $wpdb;
try {
// Requête très légère ; évite les tables lourdes.
$ok = $wpdb->get_var('SELECT 1');
if ((string) $ok === '1') {
header('Content-Type: text/plain; charset=utf-8');
echo "OKn";
exit;
}
} catch (Throwable $e) {
// Ne logguez pas l'exception ici en clair (risque d'infos).
}
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo "KOn";
Détails qui comptent dans ce code (et que je corrige souvent en audit) :
SHORTINITévite de charger thèmes/plugins. Ça réduit la latence et limite les effets de bord (un plugin qui fait une requête externe pendant le bootstrap peut “tromper” votre check).- Le
try/catchévite de renvoyer un 200 avec une page d’erreur PHP (selon la config), ce qui ferait croire à HAProxy que tout va bien. - Le check
SELECT 1valide la connectivité DB. Il ne valide pas que WordPress fonctionne “fonctionnellement”, mais il détecte déjà un gros pourcentage de pannes (DB down, DNS interne cassé, credentials invalides, pool saturé).
Variante utile (edge case) : si votre panne la plus fréquente est Redis (object cache) qui “gèle” et fait exploser la latence, vous pouvez ajouter un ping Redis optionnel au health check. Attention : ne faites pas échouer tout le backend si Redis tombe et que votre site peut fonctionner sans (sinon vous transformez un incident Redis en outage complet).
<?php
// Variante : check Redis optionnel (ne doit pas fuiter d'infos)
define('SHORTINIT', true);
require __DIR__ . '/wp-load.php';
global $wpdb;
$ok_db = false;
$ok_redis = null; // null = non testé
try {
$ok_db = ((string) $wpdb->get_var('SELECT 1') === '1');
} catch (Throwable $e) {
$ok_db = false;
}
// Test Redis brut (si le serveur est joignable) - sans dépendre d'un plugin.
$redis_host = defined('WP_REDIS_HOST') ? WP_REDIS_HOST : null;
$redis_port = defined('WP_REDIS_PORT') ? (int) WP_REDIS_PORT : 6379;
if ($redis_host) {
$fp = @fsockopen($redis_host, $redis_port, $errno, $errstr, 0.2);
if ($fp) {
stream_set_timeout($fp, 0, 200000);
fwrite($fp, "*1rn$4rnPINGrn");
$resp = fgets($fp);
fclose($fp);
$ok_redis = (is_string($resp) && str_starts_with($resp, "+PONG"));
} else {
$ok_redis = false;
}
}
if ($ok_db) {
header('Content-Type: text/plain; charset=utf-8');
echo "OKn";
// Vous pouvez aussi exposer un état très minimal en header, côté réseau privé uniquement.
// header('X-Health-Redis: ' . ($ok_redis === null ? 'skip' : ($ok_redis ? 'ok' : 'ko')));
exit;
}
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo "KOn";
Oui, SHORTINIT est un compromis. Il réduit le bootstrap, mais charge quand même la config DB. Sur des sites énormes, c’est un bon équilibre. Référence générale sur le bootstrap WordPress : wp-load.php (Developer Reference).
Étape 3 : Affinité de session (stickiness) et cas WordPress (wp-admin, cookies, REST)
WordPress n’utilise pas de “session serveur” par défaut : l’auth passe par cookies signés, donc le multi-nœuds fonctionne sans stickiness si le code, les salts, et l’heure système sont cohérents. Là où ça se complique : certains plugins (e-commerce, sécurité, cache, éditeurs) utilisent des sessions PHP, des fichiers temporaires, ou des verrous disque.
Dans mon expérience, le meilleur compromis est :
- pas de stickiness pour le trafic public (meilleure répartition),
- stickiness pour
/wp-adminet/wp-login.phpsi vous suspectez des plugins “stateful”, - ou stickiness globale si vous êtes en phase de stabilisation et que vous voulez réduire les inconnues.
Edge case réel : un utilisateur loggé (Elementor/Divi/Avada builder) fait beaucoup d’AJAX via /wp-admin/admin-ajax.php et /wp-json/. Si votre cache proxy/fastcgi a une mauvaise règle sur cookies, vous pouvez servir du contenu “public” à un user loggé (ou l’inverse). On y revient au cache.
Autres cas qui déclenchent de la stickiness “inattendue” :
- Upload / import en admin : certaines opérations déclenchent des requêtes longues + polling. Si HAProxy redistribue au milieu et que le plugin stocke un état en fichier local, vous cassez le workflow.
- Nonce REST : WordPress gère les nonces côté cookies et temps, mais des proxies qui modifient les headers (ou des horloges désynchronisées) peuvent vous donner des 401/403 intermittents sur
/wp-json/. - Rate limiting côté HAProxy : si vous mettez des limites trop strictes sur
/wp-json/, Elementor peut ressembler à un bot (beaucoup de petites requêtes). Prévoyez des ACL par route plutôt qu’un “global rate limit”.
Étape 4 : Stockage partagé pour wp-content/uploads (NFS ou objet) et invalidation
Sans stockage partagé, vous allez le sentir immédiatement : une image uploadée depuis le nœud A n’existe pas sur le nœud B, donc 404 aléatoires selon la répartition. Les page builders aggravent ça (beaucoup d’uploads, régénérations).
NFS (rapide à déployer, attention aux verrous et perf)
Exemple : un serveur NFS exporte /srv/nfs/wp-uploads. Chaque nœud web monte ce répertoire sur /var/www/example.com/wp-content/uploads.
# Sur le serveur NFS
sudo apt-get install -y nfs-kernel-server
sudo mkdir -p /srv/nfs/wp-uploads
sudo chown -R www-data:www-data /srv/nfs/wp-uploads
sudo chmod 2775 /srv/nfs/wp-uploads
# /etc/exports (exemple)
# Autorisez uniquement le réseau privé de vos nœuds web
# no_root_squash est pratique mais discutable ; préférez des UID/GID cohérents.
sudo tee /etc/exports > /dev/null <<'EOF'
/srv/nfs/wp-uploads 10.0.10.0/24(rw,sync,no_subtree_check)
EOF
sudo exportfs -ra
sudo systemctl restart nfs-kernel-server
Sur chaque nœud web :
sudo apt-get install -y nfs-common
# Sauvegardez l'ancien uploads local si existant
sudo rsync -a /var/www/example.com/wp-content/uploads/ /root/uploads-local-backup/ || true
# Montez temporairement pour tester
sudo mount -t nfs4 10.0.20.10:/srv/nfs/wp-uploads /var/www/example.com/wp-content/uploads
# Persistant via /etc/fstab
sudo tee -a /etc/fstab > /dev/null <<'EOF'
10.0.20.10:/srv/nfs/wp-uploads /var/www/example.com/wp-content/uploads nfs4 rw,hard,timeo=600,retrans=2,_netdev 0 0
EOF
sudo mount -a
Point d’attention : assurez-vous que www-data (ou l’utilisateur PHP-FPM) a les mêmes UID/GID sur tous les nœuds. Sinon, vous aurez des permissions “fantômes” sur NFS.
Edge cases NFS que je vois souvent en prod :
- Stale file handle après un redéploiement NFS / reboot : vos backends commencent à renvoyer des 500 lors d’un upload. Vérifiez
dmesgsur les nœuds web et remontez proprement. Dans les cas sensibles, un stockage objet (S3 compatible) évite cette classe de problèmes. - Performance : NFS peut être “OK” sur lecture mais catastrophique sur beaucoup de petits fichiers (miniatures). Si votre médiathèque est massive, mettez un CDN devant
/wp-content/uploads/ou passez objet + plugin adapté. - Locking : certains plugins font des locks fichiers. Sur NFS, ça dépend des options et de la version. Testez les fonctionnalités critiques (backup plugin, optimisation images, import) avant la bascule.
Invalidation cache après upload
Si vous activez un cache HTTP (FastCGI), les pages qui référencent des médias peuvent rester en cache alors que l’upload vient d’avoir lieu. Sur un site éditorial, ça se voit vite. La solution robuste est une purge ciblée (par URL) via un plugin de cache compatible, ou une purge HAProxy/Varnish si vous en avez. Ici, je reste côté Nginx/FastCGI et je recommande une stratégie conservatrice (pas de cache pour les utilisateurs loggés, TTL court, purge manuelle via WP-CLI si besoin).
Autre edge case : les builders génèrent parfois des CSS/JS dans wp-content/uploads/ (ou un sous-dossier) et s’attendent à ce qu’ils soient visibles immédiatement. Si vous servez uploads via Nginx avec des headers immutable trop agressifs, vous pouvez garder une ancienne version côté navigateur. Gardez immutable pour les assets fingerprintés, pas pour des fichiers “dynamiques” régénérés sans hash.
Étape 5 : Base de données (primaire/réplique), lecture/écriture et limites réelles
Le load balancing web ne “scale” pas la base. Souvent, après avoir ajouté 2-3 nœuds web, la DB devient le goulot. WordPress écrit plus que ce que beaucoup imaginent : autoload options, transients, sessions de plugins, logs, etc.
Stratégie pragmatique :
- Commencez avec un primaire solide (CPU/RAM/IOPS), index propres, slow query log.
- Ajoutez une réplique pour les backups, la BI, et éventuellement des lectures si vous avez une couche applicative qui sait router (WordPress core ne fait pas read/write splitting nativement).
Si vous envisagez du read/write splitting, faites-le via un plugin éprouvé et testez les incohérences (réplication asynchrone). Beaucoup de bugs “fantômes” viennent de lectures sur réplique juste après une écriture (race condition). Pour un WordPress éditorial, je préfère souvent ne pas split, ou le faire uniquement sur des endpoints très contrôlés.
Référence utile (API DB WordPress) : classe wpdb.
Edge cases DB en environnement multi-nœuds :
- Connexions : multiplier les nœuds web multiplie les connexions MySQL. Ajustez
max_connectionset surtout vos pools PHP-FPM (sinon vous “DDoS” votre DB avec des connexions concurrentes). - Autoload bloat : une table
wp_optionsgonflée (autoload=yes) augmente le coût de chaque requête. Redis aide, mais ne masque pas tout si le cache est cold ou si vous avez des invalidations fréquentes. - Transactions : certains plugins font des opérations longues en transaction. Avec plusieurs nœuds, vous augmentez la probabilité de lock contention. Activez le slow log et surveillez les waits/locks.
Étape 6 : Cache serveur (FastCGI) + object cache Redis, sans se tirer une balle dans le pied
Deux couches différentes :
- Cache HTTP (Nginx FastCGI cache) : accélère les pages publiques. Très efficace, mais dangereux si vous cachez des pages personnalisées par cookies.
- Object cache (Redis) : accélère les requêtes DB répétitives (options, queries). Plus “safe” pour WordPress, et utile même pour les utilisateurs loggés.
Redis : installez Redis et utilisez un plugin d’object cache maintenu. Le fichier wp-content/object-cache.php doit être identique sur tous les nœuds (déployé via votre pipeline). Attention aux déploiements partiels : j’ai déjà vu un cluster où un nœud avait l’object-cache.php et l’autre non, donnant des perfs incohérentes et des bugs intermittents.
Référence : Transients API (utile pour comprendre ce qui peut être stocké).
Variante/edge case cache HTTP (FastCGI) : si vous l’activez, vous devez définir clairement la notion de “cacheable”. Pour WordPress, le signal le plus fiable reste la présence de cookies d’auth et de preview. Ne vous contentez pas de “ne pas cacher /wp-admin/” : un utilisateur loggé peut charger une page publique, et cette page doit rester non cacheée si elle est personnalisée (barre admin, contenu restreint, prix spécifiques, etc.).
Je n’ajoute pas ici une conf FastCGI complète (ça dépend trop de vos routes), mais voici les exclusions que je teste toujours au minimum :
- toute requête avec cookie
wordpress_logged_in_ouwordpress_sec_ wp-postpass_(contenu protégé par mot de passe)comment_author_(selon votre logique)- toutes les routes
/wp-json/,/wp-admin/,/wp-login.php,admin-ajax.php - toute requête avec méthode autre que GET/HEAD
Étape 7 : Déploiement, staging, migrations et “drain” propre d’un nœud
Deux règles qui évitent 80% des incidents en load balancing WordPress :
- Déployez atomiquement (release directory + symlink), ou au minimum synchronisez tous les nœuds avant de remettre du trafic.
- Drainez un nœud avant maintenance : laissez finir les connexions, n’acceptez plus de nouvelles.
Drain côté HAProxy
Avec le socket runtime HAProxy, vous pouvez passer un serveur en “drain” sans redémarrer.
# Exemple : mettre web2 en drain
echo "set server wp_backends/web2 state drain" | sudo socat stdio /run/haproxy/admin.sock
# Vérifier l'état
echo "show stat" | sudo socat stdio /run/haproxy/admin.sock | grep wp_backends
Ensuite vous déployez sur web2, vous le remettez “ready” :
echo "set server wp_backends/web2 state ready" | sudo socat stdio /run/haproxy/admin.sock
Edge case drain : si vous utilisez HTTP keep-alive agressif, “drain” peut prendre plus longtemps que prévu (connexions persistantes). Ajustez vos timeouts HAProxy et surveillez les connexions actives dans show stat avant de couper un service.
Staging réaliste
Votre staging doit reproduire : HAProxy (ou au moins Nginx reverse proxy), Redis, et le partage uploads. Sinon vous validez un scénario qui n’existe pas en prod.
Migration DB (exemple) : si vous clonez prod → staging, pensez à remplacer les URLs et à désactiver l’indexation. WP-CLI :
# Sur staging
wp core version
wp search-replace 'https://www.example.com' 'https://staging.example.com' --all-tables --precise
wp option update blog_public 0
# Si vous avez un cache plugin, purge (commande selon plugin)
# wp cache flush (cache objet) :
wp cache flush
Variante staging multi-nœuds : si vous avez un second domaine (staging) derrière le même HAProxy, vérifiez que vous ne partagez pas accidentellement les mêmes cookies (domain/path) entre environnements. En pratique, évitez *.example.com pour les cookies auth si staging est sur un sous-domaine accessible aux mêmes navigateurs.
Docs WP-CLI : wp search-replace.
Fichiers de configuration complets
HAProxy : /etc/haproxy/haproxy.cfg
Configuration complète (à adapter IPs/noms). Elle inclut : TLS, HTTP/2, redirection HTTP→HTTPS, headers proxy, stickiness optionnelle, health check sur /lb-health.php, et socket admin pour drain.
# /etc/haproxy/haproxy.cfg
# Configuration HAProxy pour WordPress multi-nœuds
# Testez avec : haproxy -c -f /etc/haproxy/haproxy.cfg
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
user haproxy
group haproxy
daemon
# Socket admin pour opérations runtime (drain, stats)
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
# TLS : ajustez selon votre politique
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
defaults
log global
mode http
option httplog
option dontlognull
option http-keep-alive
option forwardfor header X-Forwarded-For
timeout connect 5s
timeout client 60s
timeout server 60s
# Retries prudents : évite de multiplier la charge sur backends lents
retries 2
frontend fe_http
bind :80
http-request redirect scheme https code 301 unless { ssl_fc }
frontend fe_https
bind :443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
# Indique à l'app que l'origine est HTTPS
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Port 443
# HSTS (activez seulement si vous êtes sûr de votre HTTPS)
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Limite basique sur les uploads géants (peut être ajustée)
# Attention : certains builders importent des ZIP lourds
http-request deny if { req.body_size gt 104857600 }
default_backend wp_backends
backend wp_backends
balance roundrobin
# Cookie de stickiness (optionnel) :
# Activez si vous avez des plugins qui dépendent d'un état local.
cookie SRV insert indirect nocache
# Health check HTTP applicatif
option httpchk GET /lb-health.php
http-check expect status 200
# Transmettez l'IP backend (utile en debug)
http-response set-header X-Backend %s
# Backends (IP privées)
server web1 10.0.10.11:80 check cookie web1 inter 2s fall 3 rise 2
server web2 10.0.10.12:80 check cookie web2 inter 2s fall 3 rise 2
Edge cases HAProxy que je surveille :
- HTTP→HTTPS : si vous mettez un CDN/WAF devant, la redirection côté HAProxy peut devenir redondante, voire boucler si vous vous fiez à un header non fiable. Dans ce cas, faites la redirection au niveau du CDN, et gardez HAProxy “passif”.
- Limite
req.body_size: utile contre certains abus, mais elle casse des imports de templates (Divi/Elementor) si vous ne l’ajustez pas. Je préfère limiter par route (ex : autoriser plus sur/wp-admin/depuis des IPs internes/VPN) si vous avez une politique stricte. - Health check vs cache : si un backend a un Nginx qui renvoie 200 sur
/lb-health.phpmais que PHP-FPM est mort, votre check doit passer par PHP (comme ici), sinon HAProxy ne sortira jamais le nœud.
Référence HAProxy (docs officielles) : HAProxy Configuration Manual.
Nginx (nœuds web) : /etc/nginx/sites-available/example.com
Exemple Nginx pour WordPress derrière HAProxy. Points clés : headers proxy, vraie IP, règles FastCGI, et un bloc pour restreindre /lb-health.php à HAProxy.
# /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php;
# IP réelle depuis HAProxy (adaptez au réseau)
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Health check : autorisez uniquement HAProxy
location = /lb-health.php {
allow 10.0.0.10; # IP privée HAProxy
deny all;
include snippets/fastcgi-php.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# Fichiers statiques
location ~* .(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
# WordPress
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP
location ~ .php$ {
include snippets/fastcgi-php.conf;
# Important : script filename correct
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Timeouts (builders et imports peuvent être longs)
fastcgi_read_timeout 120s;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# Sécurité : bloquez l'accès à des fichiers sensibles
location ~* /(wp-config.php|readme.html|license.txt) {
deny all;
}
}
Remarque : j’ai mis php8.3-fpm.sock à titre d’exemple (PHP 8.1+). Adaptez au socket réel.
Edge cases Nginx/PHP-FPM fréquents sur multi-nœuds :
- Chemins de docroot différents entre nœuds (un nœud pointe sur une release, l’autre sur une autre). Résultat : 404/500 aléatoires. Imposer la même arborescence et un symlink stable est le fix le plus rentable.
- Max body size : si vous limitez côté Nginx (
client_max_body_size) et côté HAProxy, vous devez aligner les deux. Sinon vous passez du temps à diagnostiquer des 413 “parfois”. - Real IP : si vous activez
real_ip_recursivesans limiter strictementset_real_ip_fromà vos proxies, vous ouvrez la porte au spoofing d’IP via header.
wp-config.php (extraits multi-nœuds)
Ne copiez pas un vieux snippet trouvé sur un blog de 2018. Pour WordPress 6.9.4, gardez une config claire et déployée identiquement partout. Les points multi-nœuds : salts identiques, reverse proxy HTTPS, Redis, et (optionnel) désactivation des edits fichiers.
<?php
// Extraits pertinents pour un WordPress derrière HAProxy (WP 6.9.4+)
// DB (exemple)
define('DB_NAME', 'wpdb');
define('DB_USER', 'wpdb_user');
define('DB_PASSWORD', 'change-me');
define('DB_HOST', '10.0.30.10');
// Clés/salts : identiques sur tous les nœuds (sinon déconnexions)
define('AUTH_KEY', '...');
define('SECURE_AUTH_KEY', '...');
define('LOGGED_IN_KEY', '...');
define('NONCE_KEY', '...');
define('AUTH_SALT', '...');
define('SECURE_AUTH_SALT', '...');
define('LOGGED_IN_SALT', '...');
define('NONCE_SALT', '...');
// Détection HTTPS derrière reverse proxy
// Sécurité : ne faites confiance qu'à votre réseau interne (HAProxy)
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
// Désactive l'éditeur de fichiers en admin (réduit le risque)
define('DISALLOW_FILE_EDIT', true);
// Redis (si votre plugin/object-cache.php le supporte)
define('WP_REDIS_HOST', '10.0.40.10');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
// Optionnel : si vous avez un réseau séparé pour Redis
// define('WP_REDIS_PASSWORD', '...');
// Forcer URLs si vous avez des incohérences (à manier avec précaution)
// define('WP_HOME', 'https://example.com');
// define('WP_SITEURL', 'https://example.com');
Détails pratiques sur ces extraits :
- Le mapping
X-Forwarded-Proto→$_SERVER['HTTPS']est volontairement minimal. Je vois souvent des snippets qui forcent HTTPS “quoi qu’il arrive”, ce qui casse les checks internes HTTP, ou des CLI qui chargent WordPress hors contexte proxy. - Les salts identiques sont non négociables. Si vous avez une rotation de secrets, faites-la de façon coordonnée sur tous les nœuds (déploiement synchronisé), sinon vous déconnectez tout le monde “au hasard”.
- Pour Redis, gardez des timeouts courts. Un Redis lent est pire qu’un Redis down : il fait “coller” des workers PHP-FPM.
Référence sur la sécurité et l’édition de fichiers : Hardening WordPress.
php.ini (ou conf PHP-FPM) : limites adaptées aux builders
Divi/Elementor/Avada poussent souvent memory_limit et les tailles d’upload. Ajustez sans tomber dans le “2G partout”.
; /etc/php/8.3/fpm/conf.d/99-wordpress.ini
; Réglages raisonnables pour WordPress + page builders
memory_limit = 512M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 120
max_input_time = 120
max_input_vars = 5000
; OpCache (à ajuster selon votre codebase)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
Edge case PHP-FPM : alignez vos paramètres de pool (pm.max_children, pm.max_requests) avec (1) la RAM réelle, (2) la DB. Monter pm.max_children “pour encaisser” peut juste déplacer le problème sur MySQL (trop de connexions) et aggraver les timeouts.
Vérification
Vous voulez vérifier 4 choses : TLS, répartition, cohérence WordPress, et dépendances (DB/Redis/NFS).
1) Validation HAProxy
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl restart haproxy
sudo journalctl -u haproxy -n 200 --no-pager
2) Vérifier le routage et le backend servi
# Doit répondre 200 et afficher un header X-Backend (web1/web2)
curl -I https://example.com | sed -n '1,20p'
# Faites plusieurs fois : vous devez voir alterner X-Backend si pas de stickiness
for i in $(seq 1 10); do curl -sI https://example.com | grep -i '^x-backend'; done
3) Vérifier health checks
# Depuis HAProxy (ou bastion), tester l'endpoint sur chaque nœud
curl -sS http://10.0.10.11/lb-health.php
curl -sS http://10.0.10.12/lb-health.php
4) Vérifier WordPress (WP-CLI)
# Sur chaque nœud web
cd /var/www/example.com/public
wp core version
wp option get home
wp option get siteurl
wp cache flush
5) Vérifier NFS (uploads visibles partout)
# Sur web1 : créer un fichier test
sudo -u www-data bash -lc 'echo test-$(hostname) > /var/www/example.com/wp-content/uploads/lb-test.txt'
# Sur web2 : doit voir le même fichier
sudo -u www-data cat /var/www/example.com/wp-content/uploads/lb-test.txt
6) Vérifier Redis
# Sur le serveur Redis
redis-cli PING
# Sur un nœud WP : selon plugin, vérifiez via WP-CLI ou page de statut.
# À défaut, test réseau :
nc -vz 10.0.40.10 6379
Vérifications supplémentaires que je fais quasi systématiquement après une bascule :
- Horloge :
timedatectlsur tous les nœuds. Un drift peut provoquer des nonces invalides et des déconnexions. - Chaîne d’IP : comparez l’IP vue par WordPress (logs, plugin de sécurité) avec votre IP réelle. Si tout est l’IP HAProxy, vos règles anti-bruteforce et rate limiting peuvent se retourner contre vous.
- Uploads réels via l’admin (pas juste un fichier texte) : testez une image, puis une régénération de miniatures si vous avez un plugin d’optimisation.
Si ça ne marche pas
Je procède toujours dans cet ordre : réseau → HAProxy → Nginx → PHP-FPM → WordPress → DB/Redis/NFS. Sinon vous perdez du temps à “fixer” WordPress alors que c’est un header proxy manquant.
Checklist diagnostic (commandes)
# 1) HAProxy écoute ?
sudo ss -lntp | grep -E ':80|:443'
# 2) HAProxy voit ses backends UP ?
echo "show stat" | sudo socat stdio /run/haproxy/admin.sock | grep wp_backends
# 3) Logs HAProxy
sudo journalctl -u haproxy -n 200 --no-pager
# 4) Nginx OK sur un backend ?
curl -I http://10.0.10.11 | sed -n '1,20p'
sudo journalctl -u nginx -n 200 --no-pager
# 5) PHP-FPM vivant ?
sudo systemctl status php8.3-fpm --no-pager
sudo tail -n 200 /var/log/php8.3-fpm.log
# 6) DB accessible depuis backend ?
mysql -h 10.0.30.10 -u wpdb_user -p -e "SELECT 1;"
# 7) NFS monté ?
mount | grep wp-content/uploads
dmesg | tail -n 50
Variantes de diagnostic qui font gagner du temps sur les incidents “intermittents” :
- Tester un backend directement en ajoutant un header
Host:(si vous avez plusieurs vhosts) :curl -sSI -H 'Host: example.com' http://10.0.10.11/. - Vérifier la saturation PHP-FPM (si vous avez le status page activé) ou au minimum les logs “server reached pm.max_children”.
- Tracer un 504 côté HAProxy : regardez si le timeout vient de HAProxy (timeout server) ou de Nginx (fastcgi_read_timeout). Ce n’est pas la même action corrective.
Tableau de diagnostic
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Redirections en boucle HTTP↔HTTPS | X-Forwarded-Proto absent ou non interprété |
curl -I https://example.com + inspection headers |
Ajouter X-Forwarded-Proto côté HAProxy et le mapping HTTPS dans wp-config.php |
| 404 aléatoires sur images | Uploads non partagés entre nœuds | Créer un fichier sur web1, lire sur web2 | Monter NFS / passer à stockage objet, vérifier UID/GID |
| Connexion admin qui “saute” | Salts différents, horloges désynchronisées, plugin stateful | Comparer wp-config.php, vérifier NTP |
Uniformiser config, activer stickiness sur admin si besoin |
| 504 Gateway Timeout | PHP-FPM saturé ou DB lente | Logs Nginx/PHP-FPM, slow query log | Ajuster PHP-FPM (pm.*), opcache, optimiser DB, ajouter cache |
| Contenu “privé” servi publiquement | Cache HTTP mal configuré (cookies ignorés) | Tester page loggée/non loggée, comparer headers | Désactiver cache pour cookies auth, purger, revoir règles FastCGI |
Erreurs 401/403 sur /wp-json/ en édition (builders) |
Nonces invalides (horloge), cache/proxy sur REST, règles WAF trop strictes | Comparer l’heure, tester REST sans cache, logs WAF | Synchroniser NTP, exclure REST du cache, ajuster ACL/rate limiting |
| Uploads qui échouent “aléatoirement” | Limites incohérentes (HAProxy/Nginx/PHP), NFS instable | Reproduire avec un gros fichier, vérifier 413/499/504, dmesg |
Aligner tailles/timeouts, stabiliser NFS ou passer objet |
Pièges et erreurs courantes
Quelques erreurs que je vois revenir, même chez des équipes expérimentées :
- Copier la conf Nginx au mauvais endroit (site non activé, mauvais
server_name). Vérifieznginx -tet le lien danssites-enabled. - Oublier un point-virgule dans
wp-config.phpet casser un nœud seulement. Résultat : erreurs intermittentes selon la répartition. Déployez via CI et faites unphp -lsur chaque nœud. - Tester directement en production sans sauvegarde. Le load balancer rend les rollbacks plus délicats si vous changez plusieurs composants à la fois.
- Utiliser un snippet ancien pour “forcer HTTPS” qui ne respecte pas votre proxy. Avec WordPress 6.9.4, gardez un mapping minimal basé sur
X-Forwarded-Protoet limitez la confiance au réseau interne. - Cache agressif : mettre en cache
/wp-admin,/wp-json, ou ignorer les cookieswordpress_logged_in_*. C’est une fuite potentielle de contenu. - Permaliens non régénérés après migration/staging. Sur multi-nœuds, ça se manifeste comme “ça marche sur web1 mais pas web2” si les rewrite ne sont pas identiques. Vérifiez que vos conf Nginx sont identiques et régénérez via WP-CLI si besoin.
Erreurs associées (messages concrets) que vous verrez dans les logs :
- Nginx :
upstream timed out (110: Connection timed out) while reading response header from upstream(souvent PHP-FPM saturé ou DB lente). - PHP-FPM :
server reached pm.max_children setting(pool sous-dimensionné ou requêtes trop lentes). - Kernel/NFS :
nfs: server ... not responding/stale file handle(montage instable, redémarrage serveur NFS, problème réseau). - WordPress : erreurs REST 401/403 sur des actions d’édition (horloge/NTP, cache sur REST, ou plugin sécurité trop agressif).
Référence utile sur REST (souvent impliqué avec les builders) : WordPress REST API Handbook.
Sécurité serveur
Le load balancing augmente la surface : plus de machines, plus de clés, plus de points de fuite. Quelques pratiques concrètes (et vérifiables) :
- Firewall : exposez uniquement 80/443 sur HAProxy. Les nœuds web ne doivent pas être publics.
- Restreindre /lb-health.php à l’IP HAProxy (Nginx
allow/deny+ firewall). Un health endpoint public devient une sonde gratuite pour un attaquant. - Headers sécurité : HSTS (si prêt), et côté app/serveur selon besoin. Évitez d’ajouter CSP au hasard si vous avez Elementor/Divi/Avada sans l’avoir testée.
- Permissions :
wp-config.phpen 640/600, clés hors repo, et pas d’écriture sur le code si vous déployez en read-only. - Mises à jour : automatisez au moins la détection. WordPress publie les détails sur les releases : WordPress Releases.
- Surveillance : métriques HAProxy (stats socket), latence DB, taux d’erreurs 5xx, et saturation PHP-FPM.
Deux durcissements “serveur” que j’ajoute souvent sur ce type de setup :
- Isolation réseau stricte : DB/Redis/NFS accessibles uniquement depuis le réseau privé des nœuds web (security groups/iptables). Si un nœud web est compromis, vous voulez limiter les mouvements latéraux.
- Read-only code : si possible, montez le répertoire du code WordPress en lecture seule (ou au minimum, interdisez l’écriture à l’utilisateur PHP). Ça réduit fortement l’impact d’un RCE plugin.
Si vous voulez aller plus loin, suivez les changements core via Trac et GitHub miroir : WordPress Core Trac et wordpress-develop (GitHub).
Ressources
- Advanced Administration Handbook (WordPress)
- Classe wpdb (Developer Reference)
- REST API Handbook
- WP-CLI : search-replace
- WordPress Core Trac
- WordPress develop (GitHub)
- PHP versions supportées
- HAProxy configuration manual
FAQ
Dois-je activer la stickiness pour WordPress ?
Pas forcément. WordPress core fonctionne bien sans stickiness si vos nœuds sont réellement stateless (mêmes salts, même code, uploads partagés). Activez-la temporairement si vous suspectez un plugin qui utilise des sessions PHP ou des fichiers temporaires locaux, puis traitez la cause.
Pourquoi mon admin se déconnecte aléatoirement après passage derrière HAProxy ?
Le trio classique : salts différents entre nœuds, horloges système non synchronisées (NTP), ou détection HTTPS incohérente (cookies secure). Vérifiez wp-config.php identique partout et le mapping X-Forwarded-Proto.
Puis-je mettre NFS pour tout wp-content ?
Je l’évite pour tout wp-content (plugins/thèmes) en prod, sauf cas particulier. Un NFS lent ou instable impacte directement le TTFB. Préférez déployer le code localement (rsync/artefact) et ne partager que uploads (et éventuellement un répertoire de cache si vous savez exactement pourquoi).
Est-ce que FastCGI cache marche avec Elementor/Divi/Avada ?
Oui pour le trafic public, si vous excluez strictement les utilisateurs loggés et les endpoints sensibles. Le danger est de “cacher” une réponse personnalisée par cookie. Testez systématiquement en loggé/non loggé, et sur /wp-json/ et admin-ajax.php.
Comment sortir un nœud du pool sans couper les utilisateurs ?
Utilisez le mode drain HAProxy via le socket runtime. Le nœud n’accepte plus de nouvelles connexions, mais laisse finir celles en cours. Ensuite vous déployez, vous vérifiez, puis vous remettez en ready.
Que faire si la DB devient le goulot après ajout de nœuds web ?
Activez le slow query log, mesurez, puis optimisez : index, requêtes, object cache Redis, réduction des autoload options. La réplication aide pour certains workloads, mais WordPress ne split pas lecture/écriture nativement : testez soigneusement avant d’envoyer des lectures sur réplique.
Comment vérifier que chaque nœud a exactement le même code ?
Le plus fiable est un déploiement par artefact (release tar.gz signé) ou un répertoire versionné + symlink. En dépannage, comparez un hash :
# Sur chaque nœud : hash d'un ensemble représentatif
cd /var/www/example.com/public
find wp-includes wp-admin -type f -maxdepth 2 -print0 | sort -z | xargs -0 sha256sum | sha256sum
Mon health check passe alors que le site renvoie 500 sur certaines pages
Votre check est trop superficiel. Faites-le vérifier PHP + DB + (optionnel) Redis. Attention à ne pas déclencher des effets de bord (pas d’écriture, pas de cache). Si vous avez des erreurs 500 sur certaines routes, inspectez les logs PHP-FPM et activez temporairement un check plus “applicatif” sur une route dédiée.
Est-ce compatible avec un CDN ?
Oui. Le CDN se place généralement devant HAProxy (ou devant un WAF), et HAProxy reste votre point d’entrée origin. Assurez-vous de gérer correctement la chaîne d’IP (headers) et de ne faire confiance qu’aux IPs du CDN pour X-Forwarded-For.
Quelle est l’erreur la plus coûteuse que vous voyez sur ce type de setup ?
Un cache HTTP mal configuré qui sert du contenu loggé à des visiteurs publics (ou l’inverse). Ce n’est pas juste un bug : c’est un incident de sécurité. Si vous n’êtes pas 100% sûr de vos règles, commencez sans cache HTTP et ajoutez-le ensuite, prudemment.