Configurer une pile LEMP WordPress sécurisée avec des prisons chroot

Hôte · Ubuntu 24.04 LTSun noyau · un nginx · un mariadbClientshttps · http/3nginxtls 1.3 · limit_req/var/www/site1chroot · uid dédiéphp-fpm · site1OK/var/www/site2chroot · uid dédiéphp-fpm · site2CONFINÉEfailled'extension/var/www/site3chroot · uid dédiéphp-fpm · site3OKMariaDBune base par sitefaille d'extensionle mur tientsite intact
Un hôte, trois prisons. Une faille d'extension dans le site 2 reste dans le site 2.

WordPress est aussi répandu que mal servi par les guides d’hébergement à la fois à jour et sérieux sur l’isolation. Celui-ci bâtit un hôte multisite où la compromission d’un site reste dans ce site — une prison chroot par site, un pool PHP-FPM dédié dans chaque prison, nginx avec HTTP/3 devant, MariaDB derrière — et il est écrit contre les versions de la section 1, pour que vous puissiez vérifier son âge avant de lui faire confiance.

1. Versions contre lesquelles ce guide est testé #

Tout ce qui suit est écrit contre une poignée de variables plutôt que des numéros codés en dur : les versions vivent dans ce tableau et dans un seul bloc de variables d’interpréteur, pour que ce que le guide suppose se voie d’un coup d’œil.

ComposantCe guideNotes
Ubuntu24.04 LTS (Noble Numbat)Soutien standard jusqu’en mai 2029. 26.04 LTS (Resolute Raccoon, parue le 23 avril 2026, soutenue jusqu’en mai 2031) accepte les mêmes commandes; là où les deux diffèrent, le texte le dit. 22.04 quitte le soutien standard en juin 2027 — n’y bâtissez rien de neuf.
PHP8.5Soutien actif jusqu’au 31 décembre 2027, correctifs de sécurité jusqu’au 31 décembre 2029; pleinement prise en charge par WordPress 7.x. 26.04 la livre; 24.04 la prend d’un PPA (section 5). 8.4 est le choix prudent (correctifs de sécurité jusqu’en décembre 2028). Ne bâtissez rien de neuf sur 8.2 (correctifs de sécurité jusqu’au 31 décembre 2026) ni sur 8.1 (fin de vie).
MariaDB10.11 LTS sur 24.04 · 11.8 LTS sur 26.04Depuis les dépôts d’Ubuntu. L’exigence affichée par WordPress est maintenant MariaDB 10.11+ ou MySQL 8.0+.
nginx1.30.x (stable)Depuis nginx.org, pas le paquet de la distribution : HTTP/3 exige la 1.25 ou plus récente, et 24.04 livre la 1.24.
Certbotsnap (classic)Le paquet apt n’est plus l’installation recommandée.
WordPressversion stable courante (7.1 au moment d’écrire ces lignes)Les versions mineures se mettent à jour d’elles-mêmes; les majeures se font délibérément.

Définissez-les une fois, dans chaque interpréteur de commandes que vous utilisez pour ce guide (ou mettez-les dans un fichier et faites source). Les commandes ci-dessous les développent dans votre interpréteur avant même que sudo ne les voie :

bash
export UBUNTU_CODENAME=noble        # noble = 24.04 · resolute = 26.04
export PHP_VERSION=8.5
export SITE=site1                   # nom court : utilisateur, groupe, pool, prison, socket, base de données
export DOMAIN=site1.example.com
export JAIL=/var/www/${SITE}

Les fichiers de configuration ne lisent pas les variables de l’interpréteur : le fichier de pool et les blocs serveur nginx ci-dessous contiennent donc trois littéraux — site1, site1.example.com et 8.5 — et le disent dès leur première ligne. Changez ces trois-là, et rien d’autre.

2. Pourquoi une prison par site #

La menace contre laquelle on conçoit n’est pas « quelqu’un s’introduit dans le serveur ». C’est le cas, bien plus fréquent, d’« une extension WordPress qui a une vulnérabilité de téléversement de fichiers ». Sans isolation, cette seule extension donne à l’attaquant un accès en lecture à tous les autres sites de la machine — y compris chaque wp-config.php, et donc chaque base de données.

Chaque site reçoit donc :

  • son propre utilisateur et son propre groupe Unix
  • sa propre prison chroot, qui est aussi le répertoire personnel de cet utilisateur
  • son propre pool PHP-FPM, exécuté sous cet utilisateur et enfermé dans la prison
  • sa propre base de données et son propre utilisateur de base de données

Un site compromis voit un système de fichiers qui contient ce site et presque rien d’autre.

3. Système de base #

Partez d’un administrateur non root avec sudo et une authentification SSH par clés — le guide de configuration initiale de DigitalOcean pour 24.04 couvre encore bien ce volet. Ensuite :

bash
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y curl gnupg ca-certificates software-properties-common unattended-upgrades ufw
sudo dpkg-reconfigure --priority=low unattended-upgrades

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp          # HTTP/3, c'est QUIC, et QUIC, c'est de l'UDP
sudo ufw enable

unattended-upgrades est installé et activé pour les mises à jour de sécurité sur une 24.04 d’origine; l’étape de reconfiguration vous permet de le confirmer plutôt que de le supposer. Des correctifs de sécurité qui s’appliquent d’eux-mêmes valent plus que le petit risque d’un redémarrage non surveillé — et l’alternative, comme nous l’avons vu sur de vrais parcs, ce sont des machines qui accumulent tranquillement des années de retard. Ce qu’unattended-upgrades ne peut pas faire, c’est vous faire passer d’une version majeure à l’autre : mettez un rappel au calendrier chaque janvier pour vérifier les dates de fin de soutien de PHP, de MariaDB, de la branche stable de nginx et de votre Ubuntu LTS — chacune est publiée des années à l’avance. software-properties-common fournit add-apt-repository, dont la section 5 a besoin.

4. MariaDB #

Pourquoi MariaDB plutôt que MySQL : MySQL 8.0 a atteint sa fin de vie en amont le 30 avril 2026, et les deux LTS courantes d’Ubuntu portent désormais des versions majeures différentes de MySQL — 8.0, corrigée par Canonical, sur 24.04 et 8.4 sur 26.04 — qui diffèrent sur des points qui mordent (8.4 désactive mysql_native_password par défaut). MariaDB raconte une seule histoire sur les deux : une LTS tirée des dépôts d’Ubuntu, avec des dates de fin de soutien publiées. WordPress prend en charge l’une comme l’autre.

UbuntuMariaDB dans les dépôts d’UbuntuSoutien en amont jusqu’en
24.04 LTS10.11 LTS (universe; les mises à jour et correctifs de sécurité ont suivi depuis la parution)février 2028
26.04 LTS11.8 LTS (promue dans main)juin 2028

La page des exigences de WordPress demande maintenant MariaDB 10.11 ou plus récente : le paquet de 24.04 atteint exactement le plancher, et celui de 26.04 le dépasse confortablement.

bash
sudo apt install -y mariadb-server
sudo mariadb-secure-installation

C’est toute l’installation. Sur Ubuntu, le compte root de la base de données s’authentifie par le socket Unix : sudo mariadb vous ouvre la porte sans mot de passe. S’il vous faut précisément 11.8 sur 24.04, le dépôt APT de MariaDB est la voie prise en charge — mais pour un hôte WordPress, le paquet de la distribution est le choix qui demande le moins d’entretien.

Base de données et utilisateur par site. La permission est limitée à un seul schéma, et l’utilisateur est créé pour 127.0.0.1, pas localhost — à l’intérieur de la prison, il n’y a pas de /run/mysqld/mysqld.sock, donc PHP se connectera en TCP (la section 11 y revient) :

bash
DBPASS="$(openssl rand -base64 24)" && echo "mot de passe de la base ${SITE} : ${DBPASS}"
sudo mariadb <<SQL
CREATE DATABASE ${SITE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
CREATE USER '${SITE}'@'127.0.0.1' IDENTIFIED BY '${DBPASS}';
GRANT ALL PRIVILEGES ON ${SITE}.* TO '${SITE}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

utf8mb4_unicode_520_ci est la collation que WordPress choisit lui-même quand le serveur l’offre : la base et ses tables s’entendent dès le premier jour.

Note

MariaDB 10.5 et les versions suivantes livrent des outils préfixés mariadb (mariadb, mariadb-dump, mariadb-secure-installation). Les anciens noms mysql* demeurent comme liens symboliques de compatibilité, donc les scripts existants continuent de fonctionner — mais préférez les nouveaux noms dans tout ce que vous écrivez aujourd’hui.

5. PHP #

Ubuntu 26.04 livre PHP 8.5 dans ses propres dépôts. 24.04 livre 8.3 : sur 24.04, prenez donc 8.5 du PPA ondrej/php, largement reconnu — une ligne de plus, et chaque nom de paquet ci-dessous est identique sur les deux versions. WordPress 7.x prend 8.5 en charge d’emblée; si une extension dont vous dépendez n’a pas suivi, 8.4 est un choix sûr et la variable de la section 1 en fait un changement d’une ligne.

bash
# 24.04 seulement — 26.04 a 8.5 dans ses propres dépôts : sautez cette ligne là-bas
sudo add-apt-repository -y ppa:ondrej/php && sudo apt update

sudo apt install -y \
  php${PHP_VERSION}-fpm php${PHP_VERSION}-cli php${PHP_VERSION}-mysql php${PHP_VERSION}-curl \
  php${PHP_VERSION}-gd php${PHP_VERSION}-mbstring php${PHP_VERSION}-xml php${PHP_VERSION}-zip \
  php${PHP_VERSION}-intl php${PHP_VERSION}-imagick php${PHP_VERSION}-bcmath

Désactivez le pool par défaut pour que rien ne s’exécute jamais hors prison. FPM refuse de démarrer sans aucun pool : le redémarrage attend donc la section 9, une fois le premier pool enfermé en place :

bash
sudo mv /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf \
        /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf.disabled

6. Les prisons chroot #

6.1 Créer l’utilisateur et la prison #

La prison est le répertoire personnel de l’utilisateur, mais sa racine reste la propriété de root : l’utilisateur du site possède ce qu’il doit écrire — la racine documentaire et un tmp — et rien d’autre. --no-create-home empêche adduser de copier des fichiers d’interpréteur dans un répertoire où personne n’ouvrira jamais de session.

bash
sudo adduser --comment "${SITE} web" --disabled-password --shell /usr/sbin/nologin \
             --home ${JAIL} --no-create-home ${SITE}
sudo mkdir -p ${JAIL}/public ${JAIL}/tmp
sudo chown ${SITE}:${SITE} ${JAIL}/public ${JAIL}/tmp
sudo chmod 1777 ${JAIL}/tmp

À l’intérieur de la prison, la racine documentaire est /public, parce que du point de vue de PHP, la prison est /. Le tmp compte : c’est là que PHP déposera les téléversements et les sessions une fois qu’il ne voit plus le vrai /tmp.

6.2 Lier la poignée de chemins système dont PHP a besoin #

Un PHP enfermé a encore besoin de quelques fichiers : les données de fuseaux horaires, /dev/urandom, la résolution DNS et le paquet de certificats racines pour les connexions HTTPS sortantes. Ils sont montés en lecture seule (bind mount) dans chaque prison par un petit utilitaire, php-chroot-bind, qui n’a pas eu besoin d’une révision depuis 2018 — épinglez donc la révision et vérifiez l’empreinte plutôt que de faire confiance à master :

bash
REV=39c7fa6454473780cacfd883bedc6d5c215c25da
sudo curl -fsSL -o /usr/local/sbin/php-chroot-bind \
  https://raw.githubusercontent.com/68b32/php-chroot-bind/${REV}/php-chroot-bind
echo "68893938e8e252fd35b730d50ceeb1d45fb9182280aa4b3ad82ffe64ea90c08f  /usr/local/sbin/php-chroot-bind" | sha256sum -c
sudo chmod 755 /usr/local/sbin/php-chroot-bind

Deux retouches d’une ligne le font nôtre. Le script trouve les prisons en listant la disposition /home/www/*/chroot de son auteur; les nôtres sont inscrites dans une liste. Et son générateur systemd code en dur php7.0-fpm.service, de sorte que les unités qu’il écrit s’ordonneraient par rapport à un service qui n’existe pas — exactement pourquoi tant d’installations se rabattent sur une entrée cron @reboot.

bash
sudo sed -i 's#^_CHROOTS_CMD=.*#_CHROOTS_CMD="cat /var/www/chroot.list"#' /usr/local/sbin/php-chroot-bind
sudo sed -i "s/php7\.0-fpm\.service/php${PHP_VERSION}-fpm.service/g"      /usr/local/sbin/php-chroot-bind

Inscrivez la prison, liez, puis générez les unités de démarrage :

bash
echo "${JAIL}" | sudo tee -a /var/www/chroot.list
sudo php-chroot-bind bind
sudo php-chroot-bind status                  # chaque chemin affiche un + vert
sudo php-chroot-bind systemd update          # écrit les unités .mount et une cible php-chroots.target
sudo systemctl enable php-chroots.target     # la cible est voulue par php8.5-fpm.service et ordonnée avant lui

L’ensemble qu’il lie dans chaque prison :

text
/usr/share/zoneinfo
/dev/urandom  /dev/zero  /dev/null
/etc/resolv.conf
/lib/x86_64-linux-gnu/libnss_dns.so.2
/usr/share/ca-certificates
/etc/ssl/certs

Les extras vont dans /var/www/bind.conf, un chemin absolu par ligne — le script lit ../bind.conf par rapport à chaque prison, donc avec des prisons directement sous /var/www, ce seul fichier s’applique à toutes. Deux candidats : WordPress n’utilise ImageMagick par l’extension imagick que si la bibliothèque peut charger ses modules d’encodage, qui vivent hors de la prison (/usr/lib/x86_64-linux-gnu/ImageMagick-6.9.12 sur 24.04 — vérifiez avec ls -d /usr/lib/x86_64-linux-gnu/ImageMagick-*); sans eux, WordPress se rabat silencieusement sur GD, ce qui convient à la plupart des sites. Et rien ne peut lier un transport de courriel dans une prison : le mail() de PHP n’a aucun sendmail à appeler, donc le courriel de WordPress passe par une extension SMTP branchée sur un vrai fournisseur — ce que vous devriez faire de toute façon pour la délivrabilité.

7. nginx (avec HTTP/3) #

Le paquet de la distribution 24.04 est la 1.24, antérieure à HTTP/3. Utilisez le dépôt stable de nginx.org (1.30 au moment d’écrire ces lignes; la branche principale est la 1.31), et épinglez-le pour qu’une mise à niveau de la distribution ne puisse jamais l’écarter :

bash
curl -fsSL https://nginx.org/keys/nginx_signing.key \
  | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu ${UBUNTU_CODENAME} nginx" \
  | sudo tee /etc/apt/sources.list.d/nginx.list
printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' \
  | sudo tee /etc/apt/preferences.d/99nginx
sudo apt update && sudo apt install -y nginx
sudo systemctl enable --now nginx

Les paquets de nginx.org exécutent leurs processus de travail sous l’utilisateur nginx, pas le www-data d’Ubuntu — les permissions du socket à la section 9 en dépendent. Remplacez maintenant la page d’accueil du paquet par un bloc fourre-tout qui ne répond rien aux noms d’hôte que vous ne servez pas — pas même une poignée de main TLS. Sans lui, le premier bloc serveur de nginx répond à tout nom d’hôte égaré et à l’adresse IP nue, et votre site apparaît dans des balayages où il n’avait rien à faire :

/etc/nginx/conf.d/00-default.conf
# Tout ce qui n'est pas l'un de nos noms d'hôte ne reçoit rien — pas même une poignée de main TLS
server {
    listen 80  default_server;
    listen [::]:80  default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_reject_handshake on;
    return 444;
}
bash
sudo rm /etc/nginx/conf.d/default.conf

Puis le bloc du site sur le port 80. Il est permanent : il sert les défis de certificat et redirige tout le reste vers HTTPS, et il ne change plus jamais :

/etc/nginx/conf.d/site1-http.conf
# littéraux à changer : site1.example.com, /var/www/site1
server {
    listen 80;
    listen [::]:80;
    server_name site1.example.com;

    location ^~ /.well-known/acme-challenge/ { root /var/www/site1/public; }
    location / { return 301 https://$host$request_uri; }
}
bash
sudo nginx -t && sudo systemctl reload nginx

8. TLS avec Certbot #

Le module nginx de Certbot réécrira volontiers vos blocs serveur à votre place. Ce guide garde la configuration sous votre contrôle : Certbot ne fait que prouver la propriété par la racine web et écrire le certificat; le bloc serveur de la section 10 y fait référence explicitement.

bash
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
sudo certbot certonly --webroot -w ${JAIL}/public -d ${DOMAIN} \
     --agree-tos -m webmaster@${DOMAIN} --no-eff-email

Le renouvellement s’installe lui-même comme minuterie systemd, mais un certificat renouvelé n’entre en vigueur que lorsque nginx se recharge — avec certonly, c’est un crochet de déploiement que vous ajoutez vous-même. Vérifiez ensuite plutôt que de supposer :

bash
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
printf '#!/bin/sh\nsystemctl reload nginx\n' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

Let’s Encrypt a cessé d’envoyer des courriels d’expiration en 2025 : la répétition à blanc et la minuterie sont vos seules assurances — vérifiez-les après tout changement au DNS du site ou au bloc du port 80.

9. Le pool PHP-FPM #

/etc/php/8.5/fpm/pool.d/site1.conf
; littéraux à changer : [site1], php8.5 dans le chemin du socket
[site1]
user  = $pool
group = $pool

listen       = /run/php/php8.5-fpm-$pool.sock
; les paquets de nginx.org exécutent leurs processus sous "nginx"; le paquet nginx d'Ubuntu utiliserait www-data
listen.owner = nginx
listen.group = nginx
listen.mode  = 0660

chroot = /var/www/$pool
chdir  = /public

; Neutraliser les fonctions qu'utilise à peu près chaque web shell PHP
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,parse_ini_file,show_source
php_admin_flag[allow_url_fopen]    = off

; Ce que la prison retire, on le redonne délibérément
php_admin_value[session.save_path]   = /tmp
; correspond au client_max_body_size de nginx (section 10); la valeur par défaut de PHP est 2M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size]       = 64M

; Dimensionnement — lisez la section 9.1 avant de changer ces valeurs
pm                   = dynamic
pm.max_children      = 5
pm.start_servers     = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests      = 500

Utiliser $pool pour l’utilisateur, le groupe, le socket et la prison signifie que ce fichier est identique pour chaque site, à l’exception de l’en-tête de section et du dimensionnement. Notez que le chemin du socket est hors de la prison — le processus maître de FPM le crée avant que les processus de travail ne s’y enferment, ce qui explique que le pool écoute sous /run/php/... alors que son /tmp à lui est celui de la prison.

La liste disable_functions compte huit entrées, pas les neuf qu’on voit sur bien des listes de durcissement : curl_multi_exec est un appel légitime de client HTTP que les extensions utilisent pour des requêtes parallèles, pas une primitive d’interpréteur de commandes, et le désactiver produit des erreurs fatales au mauvais endroit. Si une extension a besoin de proc_open (rare — d’ordinaire un outil de compilation qui a fui en production), elle vous dit quelque chose.

bash
sudo systemctl restart php${PHP_VERSION}-fpm
systemctl --no-pager --lines=0 status php${PHP_VERSION}-fpm
ls -l /run/php/php${PHP_VERSION}-fpm-${SITE}.sock       # srw-rw---- nginx nginx

9.1 Dimensionner pm.max_children — la partie qui mord #

C’est le réglage le plus susceptible de faire tomber un site, dans un sens comme dans l’autre. Deux modes de défaillance réels, tous deux observés en production :

  • Trop haut. Une machine de 2 Go avec pm.max_children = 150 promet 150 processus d’environ 30 Mo chacun — 4,5 Go qu’elle n’a pas. Sous charge, elle épuise la mémoire vive, s’enlise dans la mémoire d’échange, et toute la machine cesse de répondre, jusqu’au redémarrage.
  • Trop bas. La même machine plafonnée à 20 a cessé de figer, mais s’est mise à renvoyer des HTTP 502 sous charge : tous les processus occupés, la file d’attente du socket pleine, nginx refusant sur-le-champ. L’erreur à chercher est connect() to unix:/run/php/... failed (11: Resource temporarily unavailable).

La seconde défaillance est la bonne. Une requête mise en file ou refusée se récupère; une machine figée faute de mémoire, non. Dimensionnez délibérément :

text
pm.max_children  ≈  (mémoire disponible pour PHP) / (RSS moyen d'un processus)

Mesurez le chiffre réel plutôt que de deviner. Le compte inclut le processus maître, et le RSS impute les pages partagées d’opcache à chaque processus : le résultat surestime — c’est un chiffre prudent, le bon genre :

bash
ps -C php-fpm${PHP_VERSION} -o rss= | awk '{s+=$1; n++} END {printf "moyenne %.0f Mo sur %d processus\n", s/n/1024, n}'

Sur une machine de 2 Go avec MariaDB et des processus d’environ 30 Mo, 20 à 30 est réaliste. S’il vous faut vraiment plus de concurrence, ajoutez de la mémoire — ne relevez pas le plafond en espérant.

Deux réglages connexes méritent un regard : pm.max_requests recycle les processus pour borner les fuites, et le fastcgi_read_timeout de nginx, qui, laissé à quelque chose comme 1800s, permet à une seule requête coincée de retenir un processus pendant une demi-heure.

10. Le bloc serveur du site #

/etc/nginx/conf.d/site1.conf
# littéraux à changer : site1, site1.example.com, php8.5 dans le chemin du socket
# Zone de limitation pour la page de connexion (section 12.2). Les noms de zone sont globaux — une par site.
limit_req_zone $binary_remote_addr zone=site1_login:10m rate=6r/m;

server {
    server_name site1.example.com;

    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;          # HTTP/3 — reuseport sur exactement un serveur par adresse:port
    listen [::]:443 quic reuseport;
    http2 on;

    ssl_certificate     /etc/letsencrypt/live/site1.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site1.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_tickets off;

    root  /var/www/site1/public;
    index index.php index.html;
    server_tokens off;
    client_max_body_size 64m;

    # Annoncer HTTP/3 aux clients qui arrivent en TCP
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # En-têtes de sécurité
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;

    # Ne jamais servir les fichiers cachés — .git, .env et compagnie (le chemin ACME vit sur le port 80)
    location ~ /\.(git|svn|env|ht) { return 404; }

    # Points d'entrée WordPress qui ne devraient pas être publics
    location ~* ^/(?:xmlrpc\.php|wp-config\.php|wp-links-opml\.php|readme\.html|license\.txt)$ { return 404; }

    # Aucune exécution PHP sous uploads — la règle WordPress la plus précieuse qui soit.
    # Volontairement sans $ final : /uploads/x.php/y.jpg doit correspondre aussi (section 12.1)
    location ~* ^/wp-content/uploads/.*\.php { return 404; }

    location / { try_files $uri $uri/ /index.php?$args; }

    location = /wp-login.php {
        limit_req zone=site1_login burst=3 nodelay;
        include        fastcgi_params;
        fastcgi_pass   unix:/run/php/php8.5-fpm-site1.sock;
        fastcgi_param  SCRIPT_FILENAME /public$fastcgi_script_name;
        fastcgi_param  HTTP_PROXY "";
    }

    location ~ [^/]\.php(/|$) {
        try_files      $uri =404;        # le fichier doit exister exactement tel que demandé — pas de ruse PATH_INFO
        include        fastcgi_params;
        fastcgi_pass   unix:/run/php/php8.5-fpm-site1.sock;
        fastcgi_param  SCRIPT_FILENAME /public$fastcgi_script_name;
        fastcgi_param  HTTP_PROXY "";
        fastcgi_read_timeout 300s;
    }
}

Trois détails qui piègent les gens :

  • SCRIPT_FILENAME est /public$fastcgi_script_name, pas $document_root$fastcgi_script_name. nginx est hors de la prison et PHP y est enfermé : ils ne s’entendent pas sur ce que / veut dire. try_files, en revanche, s’exécute dans nginx et vérifie le vrai chemin — exactement ce qui en fait un garde-fou utile.
  • listen 443 quic reuseport revient à exactement un bloc serveur par adresse et par port. Le fourre-tout de la section 7 n’a délibérément aucun écouteur QUIC.
  • includeSubDomains sur l’en-tête HSTS engage chaque sous-domaine du site au HTTPS, les navigateurs s’en souviennent pendant un an, et il n’y a aucune marche arrière côté client. Retirez-le si un sous-domaine, quel qu’il soit, est servi en clair.
bash
sudo nginx -t && sudo systemctl reload nginx
curl -sSI https://${DOMAIN}/ | grep -i -E '^(HTTP|alt-svc|strict-transport)'

La ligne Alt-Svc prouve que HTTP/3 est annoncé. Prouver qu’il est utilisé demande un client compatible QUIC — le panneau réseau d’un navigateur, ou curl --http3-only là où votre curl a été compilé avec.

11. WordPress, dans la prison #

bash
curl -fsSL https://wordpress.org/latest.tar.gz | sudo tar -xz -C ${JAIL}/public --strip-components=1
sudo chown -R ${SITE}:${SITE} ${JAIL}/public

Ouvrez https://site1.example.com/ et lancez l’installateur. Quand il demande l’hôte de la base de données, entrez 127.0.0.1, pas localhost : pour un client MySQL, localhost désigne le socket Unix, et le socket n’est pas dans la prison. C’est le détail qui fait trébucher plus de premières installations que tout autre.

Parce que PHP s’exécute sous le propriétaire des fichiers, WordPress se met à jour directement — pas d’identifiants FTP, pas de contournement FS_METHOD. Deux lignes ont leur place dans wp-config.php avant que le site n’approche du public :

wp-config.php
define( 'DISALLOW_FILE_EDIT', true );   // l'éditeur intégré de thèmes et d'extensions est un web shell avec une page de connexion
define( 'WP_AUTO_UPDATE_CORE', 'minor' );

12. Durcir au-delà de la pile #

12.1 Vérifiez les règles, ne les supposez pas #

Un .git dans une racine web expose tout l’historique de vos sources, et il est activement recherché. La règle sur uploads est celle qu’on contourne le plus souvent — une requête comme /wp-content/uploads/x.php/y.jpg échappe à un motif ancré sur .php$, et le traitement des chemins de PHP exécuterait volontiers x.php. Les deux sont fermés ci-dessus; vérifiez :

bash
curl -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/.git/config                         # on veut 404
curl -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/wp-content/uploads/x.php/y.jpg      # on veut 404

12.2 Limiter le débit sur la page de connexion #

6r/m avec burst=3 est généreux pour les humains et hostile au bourrage d’identifiants. Le trafic de robots qui martèle wp-login.php consommerait sinon les processus PHP que vous avez si soigneusement dimensionnés à la section 9.1. xmlrpc.php est refusé d’emblée; si vous utilisez l’application mobile WordPress ou Jetpack, les deux en ont besoin — autorisez-le alors avec le même limit_req plutôt que le 404.

12.3 Garder les outils d’administration hors de l’interface publique #

phpMyAdmin et ses semblables ne devraient pas écouter sur l’interface publique du tout — ce sont une seconde page de connexion avec le même mot de passe que la première. Pour la plupart des tâches d’administration, sudo mariadb par SSH suffit; s’il vous faut absolument un outil dans le navigateur, liez-le à l’interface de bouclage et rejoignez-le par un tunnel SSH :

bash
ssh -L 8081:127.0.0.1:8081 admin@${DOMAIN}     # puis ouvrez http://localhost:8081

12.4 Vérifier que la prison tient vraiment #

Un test lancé depuis l’interpréteur ne prouve rien ici : le PHP en ligne de commande n’est pas enfermé et ne lit pas le disable_functions du pool. Le seul test honnête passe par nginx et le pool. Déposez une sonde dans la racine documentaire, appelez-la, supprimez-la :

bash
sudo -u ${SITE} tee ${JAIL}/public/probe.php >/dev/null <<'PHP'
<?php
header('Content-Type: text/plain');
echo "la racine :   ", implode(' ', array_diff(scandir('/'), ['.', '..'])), "\n";  // la prison, pas l'hôte
echo "/etc/passwd : ", file_exists('/etc/passwd') ? 'VISIBLE' : 'absent', "\n";    // on veut : absent
echo "shell_exec :  ", function_exists('shell_exec') ? 'ACTIVE' : 'désactivée', "\n"; // on veut : désactivée
echo "uid :         ", posix_geteuid(), "\n";                                      // l'uid de l'utilisateur du site
PHP
curl -s https://${DOMAIN}/probe.php
sudo rm ${JAIL}/public/probe.php

13. Sauvegardes #

Un article sur une pile qui s’arrête à « le site est en ligne » n’a fait que la moitié du travail. Trois règles qui valent plus que n’importe quel outil :

  1. Vérifiez avant de promouvoir. Contrôlez le code de sortie du vidage (dans un script, set -o pipefail, sinon un vidage raté se cache derrière un gzip réussi), sa taille, l’intégrité du flux gzip, et la présence du marqueur final -- Dump completed. Ce dernier attrape une troncature en cours de route que rien d’autre ne détectera.
  2. Ne purgez jamais sur l’âge seul. Faites la rotation seulement après qu’une sauvegarde vérifiée existe, et gardez un plancher de N copies. Autrement, une série d’échecs silencieux supprime vos bonnes sauvegardes et les remplace par des déchets.
  3. Testez une restauration. Restaurez dans une base de travail et comparez le nombre de tables et de lignes avec la production. Une sauvegarde non testée est une hypothèse.
bash
# Cohérent ET non bloquant — mais seulement si le schéma est entièrement InnoDB (voir ci-dessous)
mariadb-dump --single-transaction --quick --routines --triggers --events \
             --default-character-set=utf8mb4 --databases ${SITE} | gzip > dump.sql.gz

gzip -t dump.sql.gz                                          # intégrité
gzip -dc dump.sql.gz | tail -c 512 | grep 'Dump completed'   # non tronqué

Avertissement

--single-transaction ne capture un instantané que des moteurs transactionnels (InnoDB). Sur un schéma qui contient des tables MyISAM, il est non bloquant mais incohérent — pire que le comportement par défaut, parce qu’il a l’air correct. Vérifiez avant de vous y fier :

SELECT engine, COUNT(*) FROM information_schema.tables WHERE table_schema = 'site1' GROUP BY engine;

Les schémas WordPress hérités sont souvent lourds en MyISAM. Le moteur Aria de MariaDB en est le successeur résistant aux pannes, et convertir ces tables (vers InnoDB ou Aria) les durcit tout en donnant un vrai sens à --single-transaction. Faites-le délibérément, d’abord sur une copie.

Les fichiers comptent autant que la base de données : les téléversements, les thèmes et wp-config.php ne figurent dans aucun vidage SQL. Les instantanés à liens physiques de rsync --link-dest vous donnent une copie quotidienne d’apparence complète pour le seul coût de ce qui a changé.

Et une sauvegarde sur le même disque n’est pas de la reprise après sinistre — elle protège contre les suppressions et les mauvais déploiements, pas contre la perte de la machine.


Besoin de faire bâtir tout ça, ou de hisser un parc WordPress existant à ce niveau? L’ingénierie et l’hébergement WordPress font partie de ce que nous faisons, et le panneau ci-dessous rejoint les personnes qui font le travail.

Contactez-nous

Un problème difficile et un budget?

Dites-nous ce qui le motive. Vous parlerez aux personnes qui font le travail.

Une phrase ou deux suffisent : le problème, le système, l'échéance.