Saltar para o conteúdo
EuroVPSEuroVPS

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.

Por EuroVPS Engineering

Atualizado em 22 de outubro de 202611 min de leitura
WordPress Multisite, EuroVPS 2024

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 partilhados wp-admin, wp-includes e wp-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=SYSTEM entrega a escolha das cifras à política criptográfica global do RHEL, pelo que o servidor segue o update-crypto-policies em vez de uma lista de cifras feita à mão.
  • No nginx 1.25.1 e posterior, listen ... http2 ainda funciona mas imprime um aviso de descontinuação. Pode escrever listen 443 ssl; mais http2 on; em alternativa. As versões mais antigas do nginx no RHEL 9 precisam da forma mostrada.
  • try_files $uri =404 no block do PHP impede o nginx de passar ao PHP-FPM pedidos de ficheiros PHP que não existem.
  • O formato de log main está definido no /etc/nginx/nginx.conf original. O server block por omissão desse ficheiro continua a responder a pedidos para hostnames desconhecidos. O seu assume o example.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 upgrade para 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/uploads em 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_timeout no 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