Configurar o WordPress Multisite com nginx e PHP-FPM em RHEL 9 e 10
Uma instalação completa e funcional de WordPress Multisite em nginx e PHP-FPM em Red Hat Enterprise Linux 9 e 10 e sistemas compatíveis: pacotes, pool PHP-FPM, server blocks nginx para redes de subdiretórios e de subdomínios, TLS, SELinux, firewalld, wp-config.php e verificação.

p
O WordPress Multisite corre muitos websites a partir de uma única instalação WordPress: uma base de código, um conjunto de plugins e temas para atualizar, uma base de dados, e um administrador de rede que controla tudo. O problema é que quase todos os guias online estão escritos para Debian ou Ubuntu, com caminhos e pacotes que não existem no Red Hat Enterprise Linux. Este guia constrói toda a stack em RHEL 9 e 10, e funciona da mesma forma em Oracle Linux, AlmaLinux e Rocky Linux.
Ao longo do guia, substitua example.com pelo seu domínio e execute os comandos como root.
h2: Subdiretório ou subdomínio?
- As redes de subdiretórios dão a cada site um caminho:
example.com/shop/,example.com/blog/. Um nome DNS e um certificado comum bastam. O nginx precisa de algumas regras de rewrite para que cada site chegue aos ficheiros partilhadoswp-admin,wp-includesewp-content. - As redes de subdomínios dão a cada site um hostname:
shop.example.com. Precisa de um registo DNS wildcard (*.example.com) e de um certificado wildcard, mas o nginx não precisa de rewrites especiais.
O WordPress só oferece a opção de subdiretórios numa instalação nova, porque num site existente os caminhos podem colidir com os permalinks dos artigos. Decida antes de começar.
h2: Passo 1: Instale os pacotes
h3: RHEL 10
O RHEL 10 não tem module streams. Os pacotes nginx, PHP e MariaDB do AppStream instalam-se diretamente:
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 com module streams do AppStream
O RHEL 9 vem com um PHP e um nginx por omissão, mais versões mais recentes como module streams. Liste-os, e depois ative o stream mais recente que a lista mostrar (os nomes de stream abaixo são exemplos):
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 com o repositório Remi
Se precisar de um ramo de PHP que o AppStream não oferece, o repositório Remi fornece-o como module streams. O Remi depende do EPEL, que no RHEL precisa do repositório CodeReady Builder (em AlmaLinux e Rocky Linux use dnf config-manager --set-enabled crb em vez da linha do 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
Os module streams do Remi substituem os pacotes PHP de base, pelo que todos os caminhos deste guia se mantêm iguais. Para RHEL 10, use o assistente de configuração do Remi em rpms.remirepo.net, que dá os comandos correspondentes para essa versão.
Vai também querer as ferramentas de gestão do SELinux para o Passo 5:
dnf install policycoreutils-python-utils
h2: Passo 2: Configure o pool PHP-FPM
O pacote php-fpm do RHEL corre o seu pool por omissão como utilizador apache, porque está preparado para o httpd. Com nginx, corremo-lo como nginx. Edite /etc/php-fpm.d/www.conf e defina estas linhas (já existem, altere os 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 o pm.max_children em função da sua RAM: meça a memória que um worker PHP médio usa sob tráfego real, e garanta que o número máximo de workers multiplicado por esse valor cabe na memória com espaço de sobra para a base de dados e para a page cache.
h3: Dê ao nginx os diretórios de sessão e de log do PHP
O pacote cria /var/lib/php/session, /var/lib/php/opcache e /var/lib/php/wsdlcache com o grupo apache, e /var/log/php-fpm com o dono apache. Um pool a correr como nginx não consegue escrever aí, e os logins falham de formas confusas. As atualizações de pacotes repõem estas propriedades, por isso defina-as com systemd-tmpfiles e reaplique-as sempre que o PHP-FPM arranca. Crie /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 -
Depois crie /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: Passo 3: Crie a base de dados
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;
Com a base de dados no mesmo servidor e DB_HOST definido como localhost, o PHP liga-se pelo socket local e o SELinux não precisa de qualquer alteração. Se a base de dados correr noutro servidor, veja o Passo 5.
h2: Passo 4: Instale o 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: defina DB_NAME, DB_USER, DB_PASSWORD e DB_HOST, e substitua as keys e os salts de exemplo por novos, obtidos no serviço de secret keys do WordPress. Acima da linha que diz «That's all, stop editing!», acrescente:
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_ALLOW_MULTISITE', true );
Acrescente apenas WP_ALLOW_MULTISITE por agora. As constantes da rede vêm mais tarde, do próprio WordPress.
Instale também o WP-CLI. É a forma mais segura de atualizar uma instalação com hardening e torna fáceis as verificações do fim:
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: Passo 5: Etiquetas e booleans do SELinux
Mantenha o SELinux em enforcing. O /var/www já tem a etiqueta httpd_sys_content_t, que o nginx e o PHP-FPM podem ler mas não escrever. É isso que quer para o código do WordPress. Só os diretórios em que o WordPress tem de escrever recebem a etiqueta de leitura e escrita:
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
O Multisite guarda os ficheiros multimédia de cada site em wp-content/uploads/sites/<id>/, pelo que a única regra cobre toda a rede. Se usar um plugin de caching que escreve em wp-content/cache, etiquete esse diretório da mesma forma.
Depois ative apenas os booleans de que precisa:
# 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 o próprio código é só de leitura para o PHP, instalar plugins ou atualizar o WordPress a partir do painel será recusado. Faça-o com o WP-CLI, como dono do site, o que também lhe dá um registo claro do que mudou:
sudo -u nginx wp --path=/var/www/example.com core update
sudo -u nginx wp --path=/var/www/example.com plugin update --all --network
Se preferir atualizações pelo painel, etiquete também wp-content/plugins e wp-content/themes com httpd_sys_rw_content_t, aceitando que o PHP passa então a poder escrever código.
h2: Passo 6: Abra a firewall
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-services
h2: Passo 7: Obtenha um certificado TLS
No RHEL, a configuração dos sites do nginx vive em /etc/nginx/conf.d/: todos os ficheiros *.conf aí presentes são carregados. Não existe sites-available nem sites-enabled. Comece com um server block HTTP simples para que o certificado possa ser emitido, em /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: Rede de subdiretórios: um certificado normal
O certbot está no EPEL. No RHEL, ative o CodeReady Builder e instale o pacote EPEL release da sua versão principal (em AlmaLinux e Rocky Linux ative o crb com dnf config-manager. Em Oracle Linux instale 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: Rede de subdomínios: um certificado wildcard
Os certificados wildcard só podem ser validados por DNS. Use o plugin DNS do certbot para o seu fornecedor de DNS, para que as renovações corram sem intervenção (por exemplo python3-certbot-dns-rfc2136 para um servidor que aceita atualizações dinâmicas), ou instale um certificado wildcard da sua própria autoridade de certificação. Os certificados que gere por si pertencem a /etc/pki/tls/certs/ e as chaves a /etc/pki/tls/private/, onde o SELinux já permite ao nginx lê-los.
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d example.com -d "*.example.com" --deploy-hook "systemctl reload nginx"
Para uma rede de subdomínios, altere também o server_name do block HTTP acima para example.com *.example.com.
h2: Passo 8: A configuração do nginx
Assim que o certificado existir, substitua /etc/nginx/conf.d/example.com.conf pela configuração completa para o seu tipo de rede. Os locations com regex são verificados pela ordem em que aparecem, por isso mantenha as regras deny acima do block do PHP.
h3: Rede de subdiretórios
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: Rede de subdomínios
A mesma estrutura sem o block de rewrites, com um server_name wildcard e o certificado wildcard. A regra try_files padrão é tudo o que o WordPress precisa, porque cada site se distingue pelo seu 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;
}
}
Algumas notas sobre estes ficheiros:
ssl_ciphers PROFILE=SYSTEMentrega a escolha das cifras à política criptográfica global do RHEL, pelo que o servidor segue oupdate-crypto-policiesem vez de uma lista de cifras feita à mão.- No nginx 1.25.1 e posterior,
listen ... http2ainda funciona mas imprime um aviso de descontinuação. Pode escreverlisten 443 ssl;maishttp2 on;em alternativa. As versões mais antigas do nginx no RHEL 9 precisam da forma mostrada. try_files $uri =404no block do PHP impede o nginx de passar ao PHP-FPM pedidos de ficheiros PHP que não existem.- O formato de log
mainestá definido no/etc/nginx/nginx.conforiginal. O server block por omissão desse ficheiro continua a responder a pedidos para hostnames desconhecidos. O seu assume oexample.com.
nginx -t && systemctl reload nginx
h2: Passo 9: Instale o WordPress e ative a rede
Abra https://example.com/ e complete o instalador normal do WordPress, ou faça-o a partir da 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 sessão, desative quaisquer plugins, e depois vá a Tools, Network Setup. Como WP_ALLOW_MULTISITE está definido, o WordPress oferece a escolha entre subdomínios e subdiretórios (numa instalação nova). Escolha, clique em Install, e o WordPress mostra um bloco de constantes para acrescentar ao wp-config.php, acima da linha «stop editing». Para uma rede de subdiretórios tem este aspeto:
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 uma rede de subdomínios, SUBDOMAIN_INSTALL é true. Copie o bloco que o WordPress lhe dá e não este exemplo. O WordPress mostra também regras .htaccess. Essas são para Apache e não fazem nada no nginx, porque a configuração nginx do Passo 8 já faz esse trabalho. Inicie sessão de novo e aparece o menu My Sites, Network Admin.
A mesma conversão pode ser feita com o WP-CLI num único passo: sudo -u nginx wp --path=/var/www/example.com core multisite-convert, acrescentando --subdomains para uma rede de subdomínios. Escreve as constantes no wp-config.php por si. Remova depois a linha WP_ALLOW_MULTISITE se quiser, já não é necessária.
h2: Passo 10: Verifique
Crie um site de teste e confirme que cada parte do caminho 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 uma rede de subdomínios use https://test.example.com/ nas mesmas verificações. Depois carregue uma imagem na biblioteca multimédia do site de teste e confirme que fica em wp-content/uploads/sites/2/. Se alguma coisa falhar, é aqui que deve procurar:
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"
Um «Permission denied» no log do PHP acompanhado de um registo AVC aponta para uma etiqueta ou um boolean do SELinux em falta (Passo 5). Um 502 do nginx significa que não conseguiu chegar ao socket do PHP-FPM: confirme que o php-fpm está a correr e que listen.acl_users inclui nginx. Ser devolvido à página de login depois de uma palavra-passe correta significa normalmente que o diretório de sessão não é gravável pelo utilizador do pool (Passo 2).
h2: Manter tudo saudável
- Atualize com
dnf upgradepara a stack e com o WP-CLI para o WordPress, e verifique o site a seguir com os testes curl acima. - Faça backup da base de dados e de
wp-content/uploadsem conjunto, a partir de um servidor de backup separado que puxa os dados, para que o servidor web não guarde credenciais dos backups. - Acrescente uma page cache (FastCGI cache do nginx ou um plugin de caching) assim que a rede tiver tráfego real, e defina
request_slowlog_timeoutno pool para ver que pedidos são lentos.
p
Os engenheiros da EuroVPS constroem e operam stacks WordPress Multisite como esta em nginx e PHP-FPM para clientes nos nossos managed servers.
Do arquivo do blog da EuroVPS, publicado pela primeira vez em 27 de janeiro de 2024.
Todas as notas de campo