Skip to content
EuroVPSEuroVPS

Configuring WordPress Multisite with Nginx and PHP-FPM on RHEL 9 and 10

A complete, working setup for WordPress Multisite on nginx and PHP-FPM on Red Hat Enterprise Linux 9 and 10 and compatible systems: packages, PHP-FPM pool, nginx server blocks for subdirectory and subdomain networks, TLS, SELinux, firewalld, wp-config.php and verification.

By EuroVPS Engineering

Updated 11 min read
Wordpress multisite EuroVPS 2024

WordPress Multisite runs many sites from one WordPress installation: one codebase, one set of plugins and themes to update, one database, and a network admin who controls it all. The catch is that almost every guide online is written for Debian or Ubuntu, with paths and packages that do not exist on Red Hat Enterprise Linux. This guide builds the whole stack on RHEL 9 and 10, and works the same on Oracle Linux, AlmaLinux and Rocky Linux.

Throughout, replace example.com with your domain and run commands as root.

Subdirectory or subdomain?

  • Subdirectory networks give each site a path: example.com/shop/, example.com/blog/. One DNS name and one ordinary certificate are enough. nginx needs a few rewrite rules so that every site can reach the shared wp-admin, wp-includes and wp-content files.
  • Subdomain networks give each site a hostname: shop.example.com. You need a wildcard DNS record (*.example.com) and a wildcard certificate, but nginx needs no special rewrites.

WordPress only offers the subdirectory option on a fresh install, because on an existing site the paths can clash with post permalinks. Decide before you start.

Step 1: Install the packages

RHEL 10

RHEL 10 has no module streams. The nginx, PHP and MariaDB packages in AppStream are installed directly:

dnf install nginx php-fpm php-cli php-mysqlnd php-gd php-intl php-mbstring php-xml php-opcache php-pecl-zip mariadb-server

RHEL 9 with AppStream module streams

RHEL 9 ships a default PHP and nginx, plus newer versions as module streams. List them, then enable the newest stream the list shows (the stream names below are examples):

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

RHEL 9 with the Remi repository

If you need a PHP branch that AppStream does not offer, the Remi repository provides it as module streams. Remi depends on EPEL, which on RHEL needs the CodeReady Builder repository (on AlmaLinux and Rocky Linux use dnf config-manager --set-enabled crb instead of the subscription-manager line):

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

Remi's module streams replace the base PHP packages, so every path in this guide stays the same. For RHEL 10, use the Remi configuration wizard on rpms.remirepo.net, which gives the matching commands for that release.

You will also want the SELinux management tools for Step 5:

dnf install policycoreutils-python-utils

Step 2: Configure the PHP-FPM pool

The RHEL php-fpm package runs its default pool as the apache user, because it is set up for httpd. With nginx we run it as nginx. Edit /etc/php-fpm.d/www.conf and set these lines (they already exist; change the values):

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

Size pm.max_children to your RAM: measure the memory an average PHP worker uses under real traffic, and make sure the maximum number of workers multiplied by that figure fits in memory with room left for the database and the page cache.

Give nginx the PHP session and log directories

The package creates /var/lib/php/session, /var/lib/php/opcache and /var/lib/php/wsdlcache with group apache, and /var/log/php-fpm owned by apache. A pool running as nginx cannot write there, and logins fail in confusing ways. Package updates reset these ownerships, so set them with systemd-tmpfiles and reapply them every time PHP-FPM starts. Create /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  -

Then create /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

Step 3: Create the database

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;

With the database on the same server and DB_HOST set to localhost, PHP connects through the local socket and SELinux needs no change. If the database runs on another server, see Step 5.

Step 4: Install 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 {} +

Edit wp-config.php: set DB_NAME, DB_USER, DB_PASSWORD and DB_HOST, and replace the placeholder keys and salts with fresh ones from the WordPress secret key service. Above the line that says "That's all, stop editing!", add:

define( 'FORCE_SSL_ADMIN', true );
define( 'WP_ALLOW_MULTISITE', true );

Add only WP_ALLOW_MULTISITE for now. The network constants come later, from WordPress itself.

Install WP-CLI as well. It is the safest way to update a hardened install and it makes the checks at the end easy:

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

Step 5: SELinux labels and booleans

Keep SELinux enforcing. /var/www is already labelled httpd_sys_content_t, which nginx and PHP-FPM can read but not write. That is what you want for the WordPress code. Only the directories WordPress must write to get the read-write 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 keeps each site's media under wp-content/uploads/sites/<id>/, so the one rule covers the whole network. If you use a caching plugin that writes to wp-content/cache, label that directory the same way.

Then switch on only the booleans you need:

# 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

Because the code itself is read-only to PHP, installing plugins or updating WordPress from the dashboard will be refused. Do it with WP-CLI instead, as the site owner, which also gives you a clear record of what changed:

sudo -u nginx wp --path=/var/www/example.com core update
sudo -u nginx wp --path=/var/www/example.com plugin update --all --network

If you prefer dashboard updates, label wp-content/plugins and wp-content/themes with httpd_sys_rw_content_t as well, accepting that PHP can then write code.

Step 6: Open the firewall

firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload
firewall-cmd --list-services

Step 7: Get a TLS certificate

On RHEL, nginx site configuration lives in /etc/nginx/conf.d/: every *.conf file there is loaded. There is no sites-available or sites-enabled. Start with a plain HTTP server block so the certificate can be issued, 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

Subdirectory network: a normal certificate

certbot is in EPEL. On RHEL, enable CodeReady Builder and install the EPEL release package for your major version (on AlmaLinux and Rocky Linux enable crb with dnf config-manager instead; on Oracle Linux install oracle-epel-release-el9 or 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

Subdomain network: a wildcard certificate

Wildcard certificates can only be validated through DNS. Use the certbot DNS plugin for your DNS provider so renewals run unattended (for example python3-certbot-dns-rfc2136 for a server that accepts dynamic updates), or install a wildcard certificate from your own certificate authority. Certificates you manage yourself belong in /etc/pki/tls/certs/ and keys in /etc/pki/tls/private/, where SELinux already allows nginx to read them.

certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d example.com -d "*.example.com" --deploy-hook "systemctl reload nginx"

For a subdomain network, also change the server_name in the HTTP block above to example.com *.example.com.

Step 8: The nginx configuration

Once the certificate exists, replace /etc/nginx/conf.d/example.com.conf with the full configuration for your network type. Regex locations are checked in the order they appear, so keep the deny rules above the PHP block.

Subdirectory network

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;
    }
}

Subdomain network

The same structure without the rewrite block, with a wildcard server_name and the wildcard certificate. The standard try_files rule is all WordPress needs, because each site is told apart by its 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;
    }
}

A few notes on these files:

  • ssl_ciphers PROFILE=SYSTEM hands cipher selection to the RHEL system-wide crypto policy, so the server follows update-crypto-policies instead of a hand-made cipher list.
  • On nginx 1.25.1 and later, listen ... http2 still works but prints a deprecation notice; you can write listen 443 ssl; plus http2 on; instead. Older nginx builds in RHEL 9 need the form shown.
  • try_files $uri =404 in the PHP block stops nginx from passing requests for PHP files that do not exist to PHP-FPM.
  • The main log format is defined in the stock /etc/nginx/nginx.conf. The default server block in that file keeps answering requests for unknown hostnames; yours takes over for example.com.
nginx -t && systemctl reload nginx

Step 9: Install WordPress and enable the network

Open https://example.com/ and complete the normal WordPress installer, or do it from the 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

Log in, deactivate any plugins, then go to Tools, Network Setup. Because WP_ALLOW_MULTISITE is set, WordPress offers the choice of subdomains or subdirectories (on a fresh install). Choose, click Install, and WordPress shows a block of constants to add to wp-config.php, above the "stop editing" line. For a subdirectory network it looks like this:

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 );

For a subdomain network, SUBDOMAIN_INSTALL is true. Copy the block WordPress gives you rather than this example. WordPress also shows .htaccess rules; those are for Apache and do nothing on nginx, because the nginx configuration in Step 8 already does that job. Log in again and the My Sites, Network Admin menu appears.

The same conversion can be done with WP-CLI in one step: sudo -u nginx wp --path=/var/www/example.com core multisite-convert, adding --subdomains for a subdomain network. It writes the constants into wp-config.php for you; remove the WP_ALLOW_MULTISITE line afterwards if you like, it is no longer needed.

Step 10: Verify

Create a test site and check that every part of the path works:

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

For a subdomain network use https://test.example.com/ in the same checks. Then upload an image in the test site's media library and confirm it lands in wp-content/uploads/sites/2/. If anything fails, these are the places to look:

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"

A "Permission denied" in the PHP log together with an AVC record points at a missing SELinux label or boolean (Step 5). A 502 from nginx means it could not reach the PHP-FPM socket: check that php-fpm is running and that listen.acl_users includes nginx. Being sent back to the login page after a correct password usually means the session directory is not writable by the pool user (Step 2).

Keeping it healthy

  • Update with dnf upgrade for the stack and WP-CLI for WordPress, and check the site afterwards with the curl tests above.
  • Back up the database and wp-content/uploads together, from a separate backup server that pulls the data, so the web server holds no credentials to the backups.
  • Add a page cache (nginx FastCGI cache or a caching plugin) once the network has real traffic, and set request_slowlog_timeout in the pool to see which requests are slow.

EuroVPS engineers build and run WordPress Multisite stacks like this on nginx and PHP-FPM for customers on our managed servers.

From the EuroVPS blog archive, first published 27 January 2024.

All field notes