WordPress Multisite mit nginx und PHP-FPM auf RHEL 9 und 10 einrichten
Eine vollständige, funktionierende Einrichtung von WordPress Multisite auf nginx und PHP-FPM auf Red Hat Enterprise Linux 9 und 10 und kompatiblen Systemen: Pakete, PHP-FPM-Pool, nginx-Server-Blocks für Subdirectory- und Subdomain-Netzwerke, TLS, SELinux, firewalld, wp-config.php und Verifikation.

p
WordPress Multisite betreibt viele Sites aus einer WordPress-Installation: eine Codebasis, ein Satz Plugins und Themes zum Aktualisieren, eine Datenbank, und ein Netzwerk-Administrator, der alles steuert. Der Haken: Fast jede Anleitung im Netz ist für Debian oder Ubuntu geschrieben, mit Pfaden und Paketen, die es auf Red Hat Enterprise Linux nicht gibt. Diese Anleitung baut den gesamten Stack auf RHEL 9 und 10 auf und funktioniert genauso auf Oracle Linux, AlmaLinux und Rocky Linux.
Ersetzen Sie durchgehend example.com durch Ihre Domain und führen Sie die Befehle als root aus.
h2: Subdirectory oder Subdomain?
- Subdirectory-Netzwerke geben jeder Site einen Pfad:
example.com/shop/,example.com/blog/. Ein DNS-Name und ein gewöhnliches Zertifikat genügen. nginx braucht ein paar Rewrite-Regeln, damit jede Site die gemeinsamen Dateien unterwp-admin,wp-includesundwp-contenterreicht. - Subdomain-Netzwerke geben jeder Site einen Hostnamen:
shop.example.com. Sie brauchen einen Wildcard-DNS-Eintrag (*.example.com) und ein Wildcard-Zertifikat, aber nginx braucht keine besonderen Rewrites.
WordPress bietet die Subdirectory-Option nur bei einer frischen Installation an, weil auf einer bestehenden Site die Pfade mit den Permalinks der Beiträge kollidieren können. Entscheiden Sie sich, bevor Sie anfangen.
h2: Schritt 1: Pakete installieren
h3: RHEL 10
RHEL 10 hat keine Module Streams. Die Pakete nginx, PHP und MariaDB aus AppStream werden direkt installiert:
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 mit Module Streams aus AppStream
RHEL 9 liefert ein Standard-PHP und -nginx mit, plus neuere Versionen als Module Streams. Lassen Sie sie auflisten und aktivieren Sie dann den neuesten Stream, den die Liste zeigt (die Stream-Namen unten sind Beispiele):
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 mit dem Remi-Repository
Wenn Sie einen PHP-Zweig brauchen, den AppStream nicht anbietet, stellt ihn das Remi-Repository als Module Streams bereit. Remi hängt von EPEL ab, das auf RHEL das CodeReady-Builder-Repository braucht (auf AlmaLinux und Rocky Linux verwenden Sie dnf config-manager --set-enabled crb statt der subscription-manager-Zeile):
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
Die Module Streams von Remi ersetzen die Basis-PHP-Pakete, sodass jeder Pfad in dieser Anleitung gleich bleibt. Für RHEL 10 verwenden Sie den Konfigurationsassistenten von Remi auf rpms.remirepo.net, der die passenden Befehle für diese Version liefert.
Sie brauchen außerdem die SELinux-Verwaltungswerkzeuge für Schritt 5:
dnf install policycoreutils-python-utils
h2: Schritt 2: Den PHP-FPM-Pool konfigurieren
Das php-fpm-Paket von RHEL betreibt seinen Standard-Pool als Benutzer apache, weil es für httpd eingerichtet ist. Mit nginx betreiben wir ihn als nginx. Bearbeiten Sie /etc/php-fpm.d/www.conf und setzen Sie diese Zeilen (sie existieren bereits, ändern Sie die Werte):
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
Dimensionieren Sie pm.max_children nach Ihrem RAM: Messen Sie den Speicher, den ein durchschnittlicher PHP-Worker unter echtem Datenverkehr belegt, und stellen Sie sicher, dass die maximale Anzahl an Workern multipliziert mit diesem Wert in den Speicher passt, mit Reserve für die Datenbank und den Page Cache.
h3: Geben Sie nginx die Session- und Log-Verzeichnisse von PHP
Das Paket legt /var/lib/php/session, /var/lib/php/opcache und /var/lib/php/wsdlcache mit der Gruppe apache an, und /var/log/php-fpm mit dem Eigentümer apache. Ein Pool, der als nginx läuft, kann dort nicht schreiben, und Logins schlagen auf verwirrende Weise fehl. Paketaktualisierungen setzen diese Eigentümerschaften zurück, also setzen Sie sie mit systemd-tmpfiles und wenden Sie sie bei jedem Start von PHP-FPM erneut an. Legen Sie /etc/tmpfiles.d/php-fpm-nginx.conf an:
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 -
Legen Sie dann /etc/systemd/system/php-fpm.service.d/10-nginx-perms.conf an:
[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: Schritt 3: Die Datenbank anlegen
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;
Mit der Datenbank auf demselben Server und DB_HOST auf localhost gesetzt verbindet sich PHP über den lokalen Socket, und SELinux braucht keine Änderung. Läuft die Datenbank auf einem anderen Server, siehe Schritt 5.
h2: Schritt 4: WordPress installieren
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 {} +
Bearbeiten Sie wp-config.php: Setzen Sie DB_NAME, DB_USER, DB_PASSWORD und DB_HOST, und ersetzen Sie die Platzhalter-Keys und -Salts durch frische vom Secret-Key-Dienst von WordPress. Fügen Sie oberhalb der Zeile «That's all, stop editing!» hinzu:
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_ALLOW_MULTISITE', true );
Fügen Sie vorerst nur WP_ALLOW_MULTISITE hinzu. Die Netzwerk-Konstanten kommen später, von WordPress selbst.
Installieren Sie auch WP-CLI. Es ist der sicherste Weg, eine gehärtete Installation zu aktualisieren, und es macht die Prüfungen am Ende einfach:
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: Schritt 5: SELinux-Labels und -Booleans
Lassen Sie SELinux auf enforcing. /var/www trägt bereits das Label httpd_sys_content_t, das nginx und PHP-FPM lesen, aber nicht schreiben können. Genau das wollen Sie für den WordPress-Code. Nur die Verzeichnisse, in die WordPress schreiben muss, bekommen das Lese-Schreib-Label:
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 legt die Medien jeder Site unter wp-content/uploads/sites/<id>/ ab, sodass die eine Regel das ganze Netzwerk abdeckt. Wenn Sie ein Caching-Plugin verwenden, das nach wp-content/cache schreibt, geben Sie diesem Verzeichnis dasselbe Label.
Schalten Sie dann nur die Booleans ein, die Sie brauchen:
# 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
Weil der Code selbst für PHP nur lesbar ist, wird das Installieren von Plugins oder das Aktualisieren von WordPress aus dem Dashboard verweigert. Erledigen Sie das stattdessen mit WP-CLI, als Eigentümer der Site, was Ihnen zugleich eine klare Aufzeichnung dessen gibt, was sich geändert hat:
sudo -u nginx wp --path=/var/www/example.com core update
sudo -u nginx wp --path=/var/www/example.com plugin update --all --network
Wenn Sie Aktualisierungen über das Dashboard bevorzugen, geben Sie auch wp-content/plugins und wp-content/themes das Label httpd_sys_rw_content_t, und nehmen Sie in Kauf, dass PHP dann Code schreiben kann.
h2: Schritt 6: Die Firewall öffnen
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-services
h2: Schritt 7: Ein TLS-Zertifikat beschaffen
Auf RHEL liegt die Site-Konfiguration von nginx in /etc/nginx/conf.d/: Jede *.conf-Datei dort wird geladen. Es gibt kein sites-available oder sites-enabled. Beginnen Sie mit einem einfachen HTTP-Server-Block, damit das Zertifikat ausgestellt werden kann, in /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: Subdirectory-Netzwerk: ein normales Zertifikat
certbot befindet sich in EPEL. Aktivieren Sie auf RHEL CodeReady Builder und installieren Sie das EPEL-Release-Paket für Ihre Hauptversion (auf AlmaLinux und Rocky Linux aktivieren Sie stattdessen crb mit dnf config-manager. Auf Oracle Linux installieren Sie oracle-epel-release-el9 oder 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: Subdomain-Netzwerk: ein Wildcard-Zertifikat
Wildcard-Zertifikate lassen sich nur über DNS validieren. Verwenden Sie das certbot-DNS-Plugin für Ihren DNS-Anbieter, damit Verlängerungen unbeaufsichtigt laufen (zum Beispiel python3-certbot-dns-rfc2136 für einen Server, der dynamische Updates annimmt), oder installieren Sie ein Wildcard-Zertifikat Ihrer eigenen Zertifizierungsstelle. Zertifikate, die Sie selbst verwalten, gehören nach /etc/pki/tls/certs/ und Schlüssel nach /etc/pki/tls/private/, wo SELinux nginx das Lesen bereits erlaubt.
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d example.com -d "*.example.com" --deploy-hook "systemctl reload nginx"
Ändern Sie für ein Subdomain-Netzwerk außerdem den server_name im HTTP-Block oben auf example.com *.example.com.
h2: Schritt 8: Die nginx-Konfiguration
Sobald das Zertifikat existiert, ersetzen Sie /etc/nginx/conf.d/example.com.conf durch die vollständige Konfiguration für Ihren Netzwerktyp. Regex-Locations werden in der Reihenfolge geprüft, in der sie erscheinen, also halten Sie die deny-Regeln oberhalb des PHP-Blocks.
h3: Subdirectory-Netzwerk
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: Subdomain-Netzwerk
Dieselbe Struktur ohne den Rewrite-Block, mit einem Wildcard-server_name und dem Wildcard-Zertifikat. Die Standardregel try_files ist alles, was WordPress braucht, weil jede Site an ihrem Hostnamen unterschieden wird:
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;
}
}
Ein paar Anmerkungen zu diesen Dateien:
ssl_ciphers PROFILE=SYSTEMüberlässt die Cipher-Auswahl der systemweiten Krypto-Richtlinie von RHEL, sodass der Serverupdate-crypto-policiesfolgt statt einer handgemachten Cipher-Liste.- Auf nginx 1.25.1 und neuer funktioniert
listen ... http2weiterhin, gibt aber einen Hinweis auf die Abkündigung aus. Sie können stattdessenlisten 443 ssl;plushttp2 on;schreiben. Ältere nginx-Builds in RHEL 9 brauchen die gezeigte Form. try_files $uri =404im PHP-Block verhindert, dass nginx Anfragen nach nicht vorhandenen PHP-Dateien an PHP-FPM weiterreicht.- Das Log-Format
mainist in der mitgelieferten/etc/nginx/nginx.confdefiniert. Der Standard-Server-Block in dieser Datei beantwortet weiterhin Anfragen für unbekannte Hostnamen. Ihrer übernimmtexample.com.
nginx -t && systemctl reload nginx
h2: Schritt 9: WordPress installieren und das Netzwerk aktivieren
Öffnen Sie https://example.com/ und durchlaufen Sie den normalen WordPress-Installer, oder erledigen Sie es aus der 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
Melden Sie sich an, deaktivieren Sie etwaige Plugins, und gehen Sie dann zu Tools, Network Setup. Weil WP_ALLOW_MULTISITE gesetzt ist, bietet WordPress die Wahl zwischen Subdomains und Subdirectories an (bei einer frischen Installation). Wählen Sie, klicken Sie auf Install, und WordPress zeigt einen Block von Konstanten, der in wp-config.php oberhalb der Zeile «stop editing» einzufügen ist. Für ein Subdirectory-Netzwerk sieht er so aus:
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 );
Für ein Subdomain-Netzwerk ist SUBDOMAIN_INSTALL true. Kopieren Sie den Block, den WordPress Ihnen gibt, und nicht dieses Beispiel. WordPress zeigt außerdem .htaccess-Regeln. Die sind für Apache und bewirken auf nginx nichts, weil die nginx-Konfiguration aus Schritt 8 diese Aufgabe bereits erledigt. Melden Sie sich erneut an, und das Menü My Sites, Network Admin erscheint.
Dieselbe Umstellung geht mit WP-CLI in einem Schritt: sudo -u nginx wp --path=/var/www/example.com core multisite-convert, mit --subdomains für ein Subdomain-Netzwerk. Es schreibt die Konstanten für Sie in wp-config.php. Entfernen Sie danach die Zeile WP_ALLOW_MULTISITE, wenn Sie möchten, sie wird nicht mehr gebraucht.
h2: Schritt 10: Verifizieren
Legen Sie eine Test-Site an und prüfen Sie, dass jeder Teil des Pfads funktioniert:
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
Für ein Subdomain-Netzwerk verwenden Sie https://test.example.com/ in denselben Prüfungen. Laden Sie dann ein Bild in die Mediathek der Test-Site hoch und bestätigen Sie, dass es in wp-content/uploads/sites/2/ landet. Wenn etwas fehlschlägt, sehen Sie hier nach:
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"
Ein «Permission denied» im PHP-Log zusammen mit einem AVC-Eintrag deutet auf ein fehlendes SELinux-Label oder -Boolean hin (Schritt 5). Ein 502 von nginx bedeutet, dass es den PHP-FPM-Socket nicht erreichen konnte: Prüfen Sie, dass php-fpm läuft und dass listen.acl_users nginx enthält. Wenn Sie nach einem korrekten Passwort zur Login-Seite zurückgeschickt werden, ist das Session-Verzeichnis meist nicht für den Pool-Benutzer beschreibbar (Schritt 2).
h2: Gesund halten
- Aktualisieren Sie mit
dnf upgradefür den Stack und mit WP-CLI für WordPress, und prüfen Sie die Site danach mit den curl-Tests oben. - Sichern Sie die Datenbank und
wp-content/uploadsgemeinsam, von einem separaten Backup-Server aus, der die Daten abholt, damit der Webserver keine Zugangsdaten zu den Backups hält. - Fügen Sie einen Page Cache hinzu (nginx FastCGI Cache oder ein Caching-Plugin), sobald das Netzwerk echten Datenverkehr hat, und setzen Sie
request_slowlog_timeoutim Pool, um zu sehen, welche Anfragen langsam sind.
p
Die Ingenieure von EuroVPS bauen und betreiben WordPress-Multisite-Stacks wie diesen auf nginx und PHP-FPM für Kunden auf unseren Managed Servern.
Aus dem Blog-Archiv von EuroVPS, erstmals veröffentlicht am 27. Januar 2024.
Alle Notizen aus der Praxis