Configurer WordPress Multisite avec nginx et PHP-FPM sur RHEL 9 et 10
Une installation complète et fonctionnelle de WordPress Multisite sur nginx et PHP-FPM sous Red Hat Enterprise Linux 9 et 10 et les systèmes compatibles : paquets, pool PHP-FPM, server blocks nginx pour les réseaux en sous-répertoires et en sous-domaines, TLS, SELinux, firewalld, wp-config.php et vérification.

p
WordPress Multisite fait tourner plusieurs sites à partir d'une seule installation WordPress : un seul code, un seul jeu de plugins et de thèmes à mettre à jour, une seule base de données, et un administrateur réseau qui contrôle le tout. Le problème est que presque tous les guides en ligne sont écrits pour Debian ou Ubuntu, avec des chemins et des paquets qui n'existent pas sur Red Hat Enterprise Linux. Ce guide construit la pile complète sur RHEL 9 et 10, et fonctionne à l'identique sur Oracle Linux, AlmaLinux et Rocky Linux.
Tout au long du guide, remplacez example.com par votre domaine et exécutez les commandes en root.
h2: Sous-répertoires ou sous-domaines ?
- Les réseaux en sous-répertoires donnent à chaque site un chemin :
example.com/shop/,example.com/blog/. Un nom DNS et un certificat ordinaire suffisent. nginx a besoin de quelques règles de rewrite pour que chaque site atteigne les fichiers partagéswp-admin,wp-includesetwp-content. - Les réseaux en sous-domaines donnent à chaque site un hostname :
shop.example.com. Il vous faut un enregistrement DNS wildcard (*.example.com) et un certificat wildcard, mais nginx n'a pas besoin de rewrites particuliers.
WordPress ne propose le choix des sous-répertoires que sur une installation neuve, parce que sur un site existant les chemins peuvent entrer en conflit avec les permaliens des articles. Décidez avant de commencer.
h2: Étape 1 : installez les paquets
h3: RHEL 10
RHEL 10 n'a pas de module streams. Les paquets nginx, PHP et MariaDB d'AppStream s'installent directement :
dnf install nginx php-fpm php-cli php-mysqlnd php-gd php-intl php-mbstring php-xml php-opcache php-pecl-zip mariadb-server
h3: RHEL 9 avec les module streams d'AppStream
RHEL 9 est livré avec un PHP et un nginx par défaut, plus des versions plus récentes sous forme de module streams. Consultez la liste, puis activez le stream le plus récent qu'elle affiche (les noms de streams ci-dessous sont des exemples) :
dnf module list php nginx
dnf module reset php nginx
dnf module enable php:8.2 nginx:1.24
dnf install nginx php-fpm php-cli php-mysqlnd php-gd php-intl php-mbstring php-xml php-opcache php-pecl-zip mariadb-server
h3: RHEL 9 avec le dépôt Remi
Si vous avez besoin d'une branche de PHP qu'AppStream ne propose pas, le dépôt Remi la fournit sous forme de module streams. Remi dépend d'EPEL, qui sur RHEL requiert le dépôt CodeReady Builder (sur AlmaLinux et Rocky Linux, utilisez dnf config-manager --set-enabled crb à la place de la ligne subscription-manager) :
subscription-manager repos --enable codeready-builder-for-rhel-9-$(arch)-rpms
dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
dnf install https://rpms.remirepo.net/enterprise/remi-release-9.rpm
dnf module reset php
dnf module enable php:remi-8.3
dnf install nginx php-fpm php-cli php-mysqlnd php-gd php-intl php-mbstring php-xml php-opcache php-pecl-zip mariadb-server
Les module streams de Remi remplacent les paquets PHP de base, donc chaque chemin de ce guide reste identique. Pour RHEL 10, utilisez le guide de configuration de Remi sur rpms.remirepo.net, qui donne les commandes correspondantes pour cette version.
Vous voudrez aussi les outils de gestion de SELinux pour l'étape 5 :
dnf install policycoreutils-python-utils
h2: Étape 2 : configurez le pool PHP-FPM
Le paquet php-fpm de RHEL exécute son pool par défaut sous l'utilisateur apache, parce qu'il est prévu pour httpd. Avec nginx, nous l'exécutons sous nginx. Éditez /etc/php-fpm.d/www.conf et définissez ces lignes (elles existent déjà, changez les valeurs) :
user = nginx
group = nginx
listen = /run/php-fpm/www.sock
listen.acl_users = nginx
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
php_value[upload_max_filesize] = 64M
php_value[post_max_size] = 64M
php_value[memory_limit] = 256M
Dimensionnez pm.max_children d'après votre RAM : mesurez la mémoire qu'utilise un worker PHP moyen sous trafic réel, et assurez-vous que le nombre maximal de workers multiplié par cette valeur tient en mémoire avec de la marge pour la base de données et le page cache.
h3: Donnez à nginx les répertoires de session et de log de PHP
Le paquet crée /var/lib/php/session, /var/lib/php/opcache et /var/lib/php/wsdlcache avec le groupe apache, et /var/log/php-fpm avec le propriétaire apache. Un pool qui tourne sous nginx ne peut pas y écrire, et les connexions échouent de façon déroutante. Les mises à jour de paquets rétablissent ces propriétés, donc définissez-les avec systemd-tmpfiles et réappliquez-les à chaque démarrage de PHP-FPM. Créez /etc/tmpfiles.d/php-fpm-nginx.conf :
z /var/lib/php/session 0770 root nginx -
z /var/lib/php/opcache 0770 root nginx -
z /var/lib/php/wsdlcache 0770 root nginx -
z /var/log/php-fpm 0770 nginx root -
Créez ensuite /etc/systemd/system/php-fpm.service.d/10-nginx-perms.conf :
[Service]
ExecStartPre=/usr/bin/systemd-tmpfiles --create /etc/tmpfiles.d/php-fpm-nginx.conf
systemctl daemon-reload
systemctl enable --now php-fpm
ls -ld /var/lib/php/session /var/log/php-fpm
ls -l /run/php-fpm/www.sock
h2: Étape 3 : créez la base de données
systemctl enable --now mariadb
mariadb-secure-installation
mariadb
CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wordpress'@'localhost' IDENTIFIED BY 'use-a-long-random-password';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wordpress'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Avec la base de données sur le même serveur et DB_HOST défini à localhost, PHP se connecte par le socket local et SELinux n'a besoin d'aucun changement. Si la base de données tourne sur un autre serveur, voyez l'étape 5.
h2: Étape 4 : installez WordPress
cd /var/www
curl -O https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
mv wordpress example.com
cd example.com
cp wp-config-sample.php wp-config.php
mkdir -p wp-content/uploads wp-content/upgrade
chown -R nginx:nginx /var/www/example.com
find /var/www/example.com -type d -exec chmod 750 {} +
find /var/www/example.com -type f -exec chmod 640 {} +
Éditez wp-config.php : définissez DB_NAME, DB_USER, DB_PASSWORD et DB_HOST, et remplacez les clés et salts fictifs par des valeurs fraîches issues du service de clés secrètes de WordPress. Au-dessus de la ligne qui dit « That's all, stop editing! », ajoutez :
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_ALLOW_MULTISITE', true );
N'ajoutez que WP_ALLOW_MULTISITE pour l'instant. Les constantes du réseau viendront plus tard, de WordPress lui-même.
Installez aussi WP-CLI. C'est la façon la plus sûre de mettre à jour une installation verrouillée et cela facilite les vérifications à la fin :
curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod 755 /usr/local/bin/wp
wp --info
h2: Étape 5 : étiquettes et booléens SELinux
Gardez SELinux en mode enforcing. /var/www porte déjà l'étiquette httpd_sys_content_t, que nginx et PHP-FPM peuvent lire mais pas écrire. C'est ce que vous voulez pour le code de WordPress. Seuls les répertoires dans lesquels WordPress doit écrire reçoivent l'étiquette de lecture et d'écriture :
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/wp-content/uploads(/.*)?"
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/wp-content/upgrade(/.*)?"
restorecon -Rv /var/www/example.com
ls -Zd /var/www/example.com/wp-content/uploads
Multisite garde les médias de chaque site sous wp-content/uploads/sites/<id>/, donc cette seule règle couvre tout le réseau. Si vous utilisez un plugin de cache qui écrit dans wp-content/cache, donnez à ce répertoire la même étiquette.
Activez ensuite seulement les booléens dont vous avez besoin :
# WordPress fetches updates, talks to APIs and runs wp-cron over HTTP
setsebool -P httpd_can_network_connect on
# only if the database is on another server
setsebool -P httpd_can_network_connect_db on
# only if WordPress sends mail through the local MTA or an SMTP server
setsebool -P httpd_can_sendmail on
Comme le code lui-même est en lecture seule pour PHP, l'installation de plugins ou la mise à jour de WordPress depuis le tableau de bord sera refusée. Faites-le plutôt avec WP-CLI, en tant que propriétaire du site, ce qui vous donne aussi un historique propre de ce qui a changé :
sudo -u nginx wp --path=/var/www/example.com core update
sudo -u nginx wp --path=/var/www/example.com plugin update --all --network
Si vous préférez les mises à jour depuis le tableau de bord, donnez aussi à wp-content/plugins et wp-content/themes l'étiquette httpd_sys_rw_content_t, en acceptant que PHP puisse alors écrire du code.
h2: Étape 6 : ouvrez le firewall
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-services
h2: Étape 7 : obtenez un certificat TLS
Sur RHEL, la configuration des sites nginx vit dans /etc/nginx/conf.d/ : chaque fichier *.conf qui s'y trouve est chargé. Il n'y a ni sites-available ni sites-enabled. Commencez par un simple server block HTTP pour que le certificat puisse être émis, dans /etc/nginx/conf.d/example.com.conf :
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
}
location / {
return 301 https://$host$request_uri;
}
}
nginx -t
systemctl enable --now nginx
h3: Réseau en sous-répertoires : un certificat ordinaire
certbot se trouve dans EPEL. Sur RHEL, activez CodeReady Builder et installez le paquet EPEL release de votre version majeure (sur AlmaLinux et Rocky Linux, activez plutôt crb avec dnf config-manager. Sur Oracle Linux, installez oracle-epel-release-el9 ou oracle-epel-release-el10) :
# RHEL 9
subscription-manager repos --enable codeready-builder-for-rhel-9-$(arch)-rpms
dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
# RHEL 10
subscription-manager repos --enable codeready-builder-for-rhel-10-$(arch)-rpms
dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm
dnf install certbot
certbot certonly --webroot -w /var/www/example.com -d example.com -d www.example.com --deploy-hook "systemctl reload nginx"
systemctl enable --now certbot-renew.timer
h3: Réseau en sous-domaines : un certificat wildcard
Les certificats wildcard ne se valident que par DNS. Utilisez le plugin DNS de certbot pour votre fournisseur DNS afin que les renouvellements tournent sans surveillance (par exemple python3-certbot-dns-rfc2136 pour un serveur qui accepte les mises à jour dynamiques), ou installez un certificat wildcard de votre propre autorité de certification. Les certificats que vous gérez vous-même vont dans /etc/pki/tls/certs/ et les clés dans /etc/pki/tls/private/, où SELinux autorise déjà nginx à les lire.
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d example.com -d "*.example.com" --deploy-hook "systemctl reload nginx"
Pour un réseau en sous-domaines, changez aussi le server_name du block HTTP ci-dessus en example.com *.example.com.
h2: Étape 8 : la configuration nginx
Une fois le certificat obtenu, remplacez /etc/nginx/conf.d/example.com.conf par la configuration complète pour votre type de réseau. Les locations par regex sont évaluées dans l'ordre où elles apparaissent, donc gardez les règles deny au-dessus du block PHP.
h3: Réseau en sous-répertoires
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
}
location / {
return 301 https://example.com$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
root /var/www/example.com;
index index.php;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers PROFILE=SYSTEM;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
client_max_body_size 64m;
access_log /var/log/nginx/example.com.access.log main;
error_log /var/log/nginx/example.com.error.log;
# Multisite subdirectory rules: map /site/wp-admin, /site/wp-includes,
# /site/wp-content and /site/*.php to the shared WordPress files.
if (!-e $request_filename) {
rewrite /wp-admin$ $scheme://$host$uri/ permanent;
rewrite ^(/[^/]+)?(/wp-.*) $2 last;
rewrite ^(/[^/]+)?(/.*\.php) $2 last;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
# never serve hidden files or run PHP from uploads
location ~ /\.(?!well-known) {
deny all;
}
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
}
location ~* \.(?:css|js|gif|ico|jpe?g|png|svg|webp|avif|woff2?)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_read_timeout 300;
}
}
h3: Réseau en sous-domaines
La même structure sans le block de rewrites, avec un server_name wildcard et le certificat wildcard. La règle standard try_files est tout ce dont WordPress a besoin, parce que chaque site se distingue par son hostname :
server {
listen 80;
listen [::]:80;
server_name example.com *.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com *.example.com;
root /var/www/example.com;
index index.php;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers PROFILE=SYSTEM;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
client_max_body_size 64m;
access_log /var/log/nginx/example.com.access.log main;
error_log /var/log/nginx/example.com.error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ /\.(?!well-known) {
deny all;
}
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
}
location ~* \.(?:css|js|gif|ico|jpe?g|png|svg|webp|avif|woff2?)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_read_timeout 300;
}
}
Quelques remarques sur ces fichiers :
ssl_ciphers PROFILE=SYSTEMdélègue le choix des ciphers à la politique cryptographique système de RHEL, de sorte que le serveur suitupdate-crypto-policiesplutôt qu'une liste de ciphers écrite à la main.- Sur nginx 1.25.1 et plus récent,
listen ... http2fonctionne encore mais affiche un avertissement d'obsolescence. Vous pouvez écrirelisten 443 ssl;plushttp2 on;à la place. Les versions plus anciennes de nginx sur RHEL 9 ont besoin de la forme que nous montrons. try_files $uri =404dans le block PHP empêche nginx de transmettre à PHP-FPM des requêtes pour des fichiers PHP qui n'existent pas.- Le format de log
mainest défini dans le/etc/nginx/nginx.confd'origine. Le server block par défaut de ce fichier continue de répondre aux requêtes pour des hostnames inconnus. Le vôtre prend en chargeexample.com.
nginx -t && systemctl reload nginx
h2: Étape 9 : installez WordPress et activez le réseau
Ouvrez https://example.com/ et terminez l'installateur WordPress habituel, ou faites-le depuis le shell :
sudo -u nginx wp --path=/var/www/example.com core install --url=https://example.com --title="Example Network" --admin_user=netadmin --admin_email=admin@example.com --prompt=admin_password
Connectez-vous, désactivez les éventuels plugins, puis allez dans Tools, Network Setup. Comme WP_ALLOW_MULTISITE est défini, WordPress propose le choix entre sous-domaines et sous-répertoires (sur une installation neuve). Choisissez, cliquez sur Install, et WordPress affiche un bloc de constantes à ajouter à wp-config.php, au-dessus de la ligne « stop editing ». Pour un réseau en sous-répertoires, cela ressemble à ceci :
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );
define( 'DOMAIN_CURRENT_SITE', 'example.com' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
Pour un réseau en sous-domaines, SUBDOMAIN_INSTALL vaut true. Copiez le bloc que WordPress vous donne, pas cet exemple. WordPress affiche aussi des règles .htaccess. Elles sont pour Apache et ne font rien sur nginx, parce que la configuration nginx de l'étape 8 fait déjà ce travail. Reconnectez-vous et le menu My Sites, Network Admin apparaît.
La même conversion se fait avec WP-CLI en une étape : sudo -u nginx wp --path=/var/www/example.com core multisite-convert, en ajoutant --subdomains pour un réseau en sous-domaines. Elle écrit les constantes dans wp-config.php pour vous. Retirez ensuite la ligne WP_ALLOW_MULTISITE si vous le souhaitez, elle n'est plus nécessaire.
h2: Étape 10 : vérifiez
Créez un site de test et vérifiez que chaque segment du chemin fonctionne :
cd /var/www/example.com
# subdirectory network
sudo -u nginx wp site create --slug=test --title="Test site" --email=admin@example.com
# subdomain network: the same command creates test.example.com
sudo -u nginx wp site list
# home page of the new site: expect 200
curl -sI https://example.com/test/ | head -1
# shared assets through the site path: expect 200
curl -sI https://example.com/test/wp-includes/js/jquery/jquery.min.js | head -1
# admin without a trailing slash redirects: expect 301 to /test/wp-admin/
curl -sI https://example.com/test/wp-admin | grep -iE "^HTTP|^location"
# login page: expect 200
curl -sI https://example.com/test/wp-login.php | head -1
# PHP in uploads is refused: expect 403
curl -sI https://example.com/wp-content/uploads/x.php | head -1
# HTTP goes to HTTPS: expect 301
curl -sI http://example.com/ | head -1
Pour un réseau en sous-domaines, utilisez https://test.example.com/ dans les mêmes vérifications. Téléversez ensuite une image dans la bibliothèque de médias du site de test et confirmez qu'elle arrive dans wp-content/uploads/sites/2/. Si quelque chose échoue, voici où regarder :
tail -50 /var/log/nginx/example.com.error.log
tail -50 /var/log/php-fpm/www-error.log
ausearch -m AVC -ts recent -i
journalctl -u php-fpm -u nginx --since "15 min ago"
Un « Permission denied » dans le log PHP accompagné d'une entrée AVC désigne une étiquette ou un booléen SELinux manquant (étape 5). Un 502 de nginx signifie qu'il n'a pas pu atteindre le socket de PHP-FPM : vérifiez que php-fpm tourne et que listen.acl_users inclut nginx. Être renvoyé à la page de connexion après un mot de passe correct signifie généralement que le répertoire de session n'est pas accessible en écriture à l'utilisateur du pool (étape 2).
h2: Le garder en bonne santé
- Mettez à jour avec
dnf upgradepour la pile et avec WP-CLI pour WordPress, et vérifiez le site ensuite avec les tests curl ci-dessus. - Sauvegardez la base de données et
wp-content/uploadsensemble, depuis un serveur de backup séparé qui tire les données, pour que le serveur web ne détienne aucun identifiant des backups. - Ajoutez un page cache (FastCGI cache de nginx ou plugin de cache) dès que le réseau a du trafic réel, et définissez
request_slowlog_timeoutdans le pool pour voir quelles requêtes sont lentes.
p
Les ingénieurs d'EuroVPS construisent et exploitent des piles WordPress Multisite comme celle-ci sur nginx et PHP-FPM pour des clients, sur nos serveurs infogérés.
Extrait des archives du blog EuroVPS, publié pour la première fois le 27 janvier 2024.
Toutes les notes de terrain