Configurar WordPress Multisite con nginx y PHP-FPM en RHEL 9 y 10
Una instalación completa y funcional de WordPress Multisite sobre nginx y PHP-FPM en Red Hat Enterprise Linux 9 y 10 y sistemas compatibles: paquetes, pool de PHP-FPM, server blocks de nginx para redes por subdirectorio y por subdominio, TLS, SELinux, firewalld, wp-config.php y verificación.

p
WordPress Multisite ejecuta muchos sitios desde una sola instalación de WordPress: un solo código, un solo conjunto de plugins y temas que actualizar, una sola base de datos, y un administrador de red que lo controla todo. El problema es que casi todas las guías en línea están escritas para Debian o Ubuntu, con rutas y paquetes que no existen en Red Hat Enterprise Linux. Esta guía construye todo el stack en RHEL 9 y 10, y funciona igual en Oracle Linux, AlmaLinux y Rocky Linux.
En toda la guía, sustituya example.com por su dominio y ejecute los comandos como root.
h2: ¿Subdirectorio o subdominio?
- Las redes por subdirectorio dan a cada sitio una ruta:
example.com/shop/,example.com/blog/. Bastan un nombre DNS y un certificado ordinario. nginx necesita unas pocas reglas de rewrite para que cada sitio alcance los archivos compartidoswp-admin,wp-includesywp-content. - Las redes por subdominio dan a cada sitio un hostname:
shop.example.com. Necesita un registro DNS wildcard (*.example.com) y un certificado wildcard, pero nginx no necesita rewrites especiales.
WordPress ofrece la opción de subdirectorio solo en una instalación nueva, porque en un sitio existente las rutas podrían chocar con los permalinks de las entradas. Decida antes de empezar.
h2: Paso 1: instale los paquetes
h3: RHEL 10
RHEL 10 no tiene module streams. Los paquetes nginx, PHP y MariaDB de AppStream se instalan directamente:
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 con los module streams de AppStream
RHEL 9 viene con PHP y nginx por defecto, más versiones más recientes como module streams. Consulte la lista y después active el stream más reciente que la lista muestre (los nombres de stream de abajo son ejemplos):
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 con el repositorio Remi
Si necesita una rama de PHP que AppStream no ofrece, el repositorio Remi la proporciona como module streams. Remi depende de EPEL, que en RHEL necesita el repositorio CodeReady Builder (en AlmaLinux y Rocky Linux use dnf config-manager --set-enabled crb en lugar de la línea de 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
Los module streams de Remi sustituyen los paquetes PHP básicos, así que todas las rutas de esta guía se mantienen. Para RHEL 10, use la guía de configuración de Remi en rpms.remirepo.net, que da los comandos correspondientes para esa versión.
También querrá las herramientas de gestión de SELinux para el Paso 5:
dnf install policycoreutils-python-utils
h2: Paso 2: configure el pool de PHP-FPM
El paquete php-fpm de RHEL ejecuta su pool por defecto como el usuario apache, porque está pensado para httpd. Con nginx lo ejecutamos como nginx. Edite /etc/php-fpm.d/www.conf y establezca estas líneas (ya existen, cambie los valores):
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
Dimensione pm.max_children según su RAM: mida la memoria que usa un worker PHP medio bajo tráfico real, y asegúrese de que el número máximo de workers multiplicado por ese valor cabe en memoria con margen para la base de datos y la page cache.
h3: Dé a nginx los directorios de sesión y de log de PHP
El paquete crea /var/lib/php/session, /var/lib/php/opcache y /var/lib/php/wsdlcache con el grupo apache, y /var/log/php-fpm con el propietario apache. Un pool que se ejecuta como nginx no puede escribir ahí, y los inicios de sesión fallan de formas confusas. Las actualizaciones de paquetes restablecen esas propiedades, así que defínalas con systemd-tmpfiles y vuelva a aplicarlas cada vez que arranque PHP-FPM. Cree /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 -
Después cree /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: Paso 3: cree la base de datos
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;
Con la base de datos en el mismo servidor y DB_HOST establecido a localhost, PHP se conecta a través del socket local y SELinux no necesita cambios. Si la base de datos se ejecuta en otro servidor, vea el Paso 5.
h2: Paso 4: instale 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 {} +
Edite wp-config.php: establezca DB_NAME, DB_USER, DB_PASSWORD y DB_HOST, y sustituya las claves y salts de ejemplo por otros nuevos del servicio de secret keys de WordPress. Encima de la línea que dice «That's all, stop editing!», añada:
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_ALLOW_MULTISITE', true );
Añada solo WP_ALLOW_MULTISITE por ahora. Las constantes de la red llegan después, del propio WordPress.
Instale también WP-CLI. Es la forma más segura de actualizar una instalación endurecida y facilita las comprobaciones del final:
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: Paso 5: etiquetas y booleans de SELinux
Mantenga SELinux en enforcing. /var/www ya lleva la etiqueta httpd_sys_content_t, que nginx y PHP-FPM pueden leer pero no escribir. Eso es lo que quiere para el código de WordPress. Solo los directorios en los que WordPress debe escribir reciben la etiqueta de lectura y escritura:
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 guarda los medios de cada sitio bajo wp-content/uploads/sites/<id>/, así que esa única regla cubre toda la red. Si usa un plugin de caching que escribe en wp-content/cache, dé a ese directorio la misma etiqueta.
Después active solo los booleans que necesita:
# 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
Como el propio código es de solo lectura para PHP, instalar plugins o actualizar WordPress desde el escritorio será rechazado. Hágalo con WP-CLI en su lugar, como el propietario del sitio, lo que además le da un historial limpio de lo que cambió:
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 prefiere las actualizaciones desde el escritorio, dé también a wp-content/plugins y wp-content/themes la etiqueta httpd_sys_rw_content_t, aceptando que PHP podrá entonces escribir código.
h2: Paso 6: abra el firewall
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-services
h2: Paso 7: obtenga un certificado TLS
En RHEL, la configuración de los sitios de nginx vive en /etc/nginx/conf.d/: cada archivo *.conf de ahí se carga. No existen sites-available ni sites-enabled. Empiece con un server block HTTP sencillo para que el certificado pueda emitirse, en /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: Red por subdirectorio: un certificado ordinario
certbot está en EPEL. En RHEL, active CodeReady Builder e instale el paquete EPEL release de su versión principal (en AlmaLinux y Rocky Linux active crb con dnf config-manager en su lugar. En Oracle Linux instale oracle-epel-release-el9 u 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: Red por subdominio: un certificado wildcard
Los certificados wildcard solo se validan por DNS. Use el plugin DNS de certbot para su proveedor de DNS para que las renovaciones se ejecuten sin supervisión (por ejemplo python3-certbot-dns-rfc2136 para un servidor que acepta actualizaciones dinámicas), o instale un certificado wildcard de su propia autoridad de certificación. Los certificados que gestiona usted mismo van en /etc/pki/tls/certs/ y las claves en /etc/pki/tls/private/, donde SELinux ya permite a nginx leerlos.
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d example.com -d "*.example.com" --deploy-hook "systemctl reload nginx"
Para una red por subdominio, cambie también el server_name del block HTTP de arriba a example.com *.example.com.
h2: Paso 8: la configuración de nginx
Una vez exista el certificado, sustituya /etc/nginx/conf.d/example.com.conf por la configuración completa para su tipo de red. Las location con expresiones regulares se evalúan en el orden en que aparecen, así que mantenga las reglas deny por encima del block de PHP.
h3: Red por subdirectorio
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: Red por subdominio
La misma estructura sin el block de rewrites, con server_name wildcard y el certificado wildcard. La regla estándar try_files es todo lo que WordPress necesita, porque cada sitio se distingue por su 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;
}
}
Unas notas sobre estos archivos:
ssl_ciphers PROFILE=SYSTEMdelega la elección de cifrados a la política criptográfica del sistema de RHEL, así que el servidor sigueupdate-crypto-policiesen lugar de una lista de cifrados escrita a mano.- En nginx 1.25.1 y posteriores,
listen ... http2sigue funcionando pero imprime un aviso de obsolescencia. Puede escribirlisten 443 ssl;máshttp2 on;en su lugar. Las versiones más antiguas de nginx en RHEL 9 necesitan la forma que mostramos. try_files $uri =404en el block de PHP impide que nginx pase a PHP-FPM peticiones de archivos PHP que no existen.- El log format
mainse define en el/etc/nginx/nginx.conforiginal. El server block por defecto de ese archivo sigue respondiendo a peticiones de hostnames desconocidos. El suyo se encarga deexample.com.
nginx -t && systemctl reload nginx
h2: Paso 9: instale WordPress y active la red
Abra https://example.com/ y complete el instalador normal de WordPress, o hágalo desde la 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
Inicie sesión, desactive cualquier plugin y después vaya a Tools, Network Setup. Como WP_ALLOW_MULTISITE está definido, WordPress ofrece la elección entre subdominios y subdirectorios (en una instalación nueva). Elija, pulse Install, y WordPress muestra un bloque de constantes para añadir a wp-config.php, encima de la línea «stop editing». Para una red por subdirectorio tiene este aspecto:
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 );
Para una red por subdominio, SUBDOMAIN_INSTALL es true. Copie el bloque que le da WordPress, no este ejemplo. WordPress muestra también reglas .htaccess. Son para Apache y en nginx no hacen nada, porque la configuración de nginx del Paso 8 ya hace ese trabajo. Vuelva a iniciar sesión y aparece el menú My Sites, Network Admin.
La misma conversión se hace con WP-CLI en un solo paso: sudo -u nginx wp --path=/var/www/example.com core multisite-convert, añadiendo --subdomains para una red por subdominio. Escribe las constantes en wp-config.php por usted. Después elimine la línea WP_ALLOW_MULTISITE si quiere, ya no hace falta.
h2: Paso 10: verifique
Cree un sitio de prueba y compruebe que cada tramo del camino funciona:
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
Para una red por subdominio use https://test.example.com/ en las mismas comprobaciones. Después suba una imagen a la biblioteca de medios del sitio de prueba y confirme que acaba en wp-content/uploads/sites/2/. Si algo falla, aquí es donde mirar:
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» en el log de PHP junto con una entrada AVC apunta a una etiqueta o un boolean de SELinux que falta (Paso 5). Un 502 de nginx significa que no pudo alcanzar el socket de PHP-FPM: compruebe que php-fpm está en ejecución y que listen.acl_users incluye nginx. Que le devuelva a la página de inicio de sesión tras una contraseña correcta suele significar que el directorio de sesión no es escribible por el usuario del pool (Paso 2).
h2: Mantenerlo sano
- Actualice con
dnf upgradepara el stack y con WP-CLI para WordPress, y compruebe el sitio después con las pruebas curl de arriba. - Haga backup de la base de datos y de
wp-content/uploadsjuntos, desde un servidor de backup separado que extraiga los datos, para que el servidor web no guarde credenciales de los backups. - Añada una page cache (FastCGI cache de nginx o un plugin de caching) en cuanto la red tenga tráfico real, y establezca
request_slowlog_timeouten el pool para ver qué peticiones son lentas.
p
Los ingenieros de EuroVPS construyen y operan stacks WordPress Multisite como este sobre nginx y PHP-FPM para clientes en nuestros managed servers.
Del archivo del blog de EuroVPS, publicado por primera vez el 27 de enero de 2024.
Todas las notas de campo