Web Servers: Apache and nginx

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), with WordPress 7.2 in beta and due in December 2026, and checked on 2026-10-28. WordPress 7.1 needs PHP 7.4 or later and recommends PHP 8.3 or later. The Hosting Handbook recommends Apache HTTPD 2.4 or nginx 1.30 (stable) or 1.31 (mainline). Every configuration below was syntax-checked with apachectl -t (Apache 2.4.69) or nginx -t (nginx 1.29.8, whose syntax for these directives is the same as 1.30), and the single-site, Multisite and sub-directory rules were exercised with curl against WordPress 7.1.3 running on PHP-FPM 8.4. Apache examples use 2.4 syntax only.

The page is for administrators who run the Lumen Press site on their own servers. The requirements for a web server, and the short nginx block that matches upload limits with PHP, are in Requirements and Hosting. This page goes further: what WordPress expects from the server, a complete Apache virtual host and .htaccess, a complete nginx server block, PHP-FPM pools, HTTP/2 and HTTP/3, TLS, and the errors that come from getting one of them wrong.

What WordPress needs from a web server

WordPress is a front controller application. Apart from static files and a few entry points (wp-login.php, wp-cron.php, xmlrpc.php and the scripts under wp-admin/), every request is answered by the root index.php, which matches the path against the rewrite_rules option in PHP. The server has three jobs: serve a file when one exists at the path, send every other path to /index.php with the query string, and run .php files through PHP without ever serving their source.

Apache routes with mod_rewrite rules, which WordPress writes into .htaccess itself. nginx does not read .htaccess; the same job is one try_files line in its configuration, written once by the administrator. The request lifecycle after index.php is described in Introduction and Architecture.

How WordPress detects the server

wp-includes/vars.php sets five globals from $_SERVER['SERVER_SOFTWARE'] on every request, and the Permalinks screen and flush_rewrite_rules() use them to decide what to write:

Global True when SERVER_SOFTWARE contains Effect

$is_apache

Apache or LiteSpeed

.htaccess is written by save_mod_rewrite_rules() when the file is writable

$is_nginx

nginx

Nothing is written; the Permalinks screen shows no rules

$is_caddy

Caddy or FrankenPHP

Nothing is written, as for nginx

$is_IIS, $is_iis7

Microsoft-IIS

web.config is written instead of .htaccess

got_mod_rewrite() asks apache_mod_loaded( 'mod_rewrite', true ), which reads the module list under mod_php but falls back to true under PHP-FPM, where the list cannot be inspected. got_url_rewrite() returns true when got_mod_rewrite() does, or when the server is nginx, Caddy or IIS 7 with the URL Rewrite module. When it returns false, the Permalinks screen falls back to /index.php/ ("PATHINFO") structures. A probe on the test servers printed:

nginx/1.29.8 apache=false nginx=true got_mod_rewrite=false got_url_rewrite=true is_ssl=true
Apache/2.4.69 (Debian) apache=true nginx=false got_mod_rewrite=true got_url_rewrite=true is_ssl=true

The got_rewrite and got_url_rewrite filters override the detection, and mod_rewrite_rules filters the block that save_mod_rewrite_rules() writes between the # BEGIN WordPress and # END WordPress markers through insert_with_markers(). The generated block, what each line does and the WP-CLI --hard flag are shown in Settings Screens; the Rewrite API itself is in Rewrite API and Permalinks.

Apache, nginx or LiteSpeed

Apache HTTPD 2.4 nginx LiteSpeed / OpenLiteSpeed

Permalinks

.htaccess written by WordPress (mod_rewrite)

try_files in the server block, once

LiteSpeed reads .htaccess; OpenLiteSpeed reads only its rewrite rules and needs a restart after a change

PHP

mod_php (prefork MPM only) or PHP-FPM through mod_proxy_fcgi (event MPM)

PHP-FPM through FastCGI

LSAPI (lsphp)

Per-directory overrides

Yes, .htaccess (costs a file lookup per directory per request)

No

LiteSpeed yes; OpenLiteSpeed rewrite rules only

HTTP/2

mod_http2

http2 on; (1.25.1+)

Yes

HTTP/3

Not in httpd 2.4; put a CDN or proxy in front

ngx_http_v3_module (1.25.0+, still marked experimental)

Yes

Page cache

mod_cache or a plugin

fastcgi_cache

LSCache with the LiteSpeed Cache plugin

LiteSpeed Web Server is a commercial server that reads Apache configuration and .htaccess, and WordPress treats it as Apache ($is_apache). OpenLiteSpeed is its open-source edition. Shared hosts often run one of them, which is why the .htaccess rules below also matter on hosts that do not run Apache.

Apache HTTPD 2.4

The virtual host with PHP-FPM

The official wordpress:*-apache images and most distribution packages start Apache with mod_php. mod_php is not thread-safe, so it requires the prefork MPM, one process per connection. The Apache HTTP/2 guide warns that under prefork mod_http2 handles only one request at a time per connection. Production servers therefore usually run the event MPM and hand PHP to a PHP-FPM pool through mod_proxy_fcgi. On Debian and Ubuntu the switch is:

sudo a2dismod php8.4 mpm_prefork
sudo a2enmod mpm_event proxy_fcgi rewrite headers expires ssl http2
sudo a2ensite lumenpress
sudo apachectl -t && sudo systemctl reload apache2

The Lumen Press virtual host redirects HTTP to HTTPS, enables HTTP/2, and sends only .php files that exist to the pool. The <If> test stops requests for non-existent scripts from reaching PHP-FPM, which would otherwise answer File not found.:

# /etc/apache2/sites-available/lumenpress.conf
<VirtualHost *:80>
    ServerName lumenpress.example
    ServerAlias www.lumenpress.example
    Redirect permanent / https://lumenpress.example/
</VirtualHost>

<VirtualHost *:443>
    ServerName lumenpress.example
    DocumentRoot /var/www/lumen

    Protocols h2 http/1.1

    SSLEngine on
    SSLCertificateFile    /etc/ssl/lumenpress/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/lumenpress/privkey.pem
    SSLProtocol           -all +TLSv1.2 +TLSv1.3
    Header always set Strict-Transport-Security "max-age=31536000"

    <Directory /var/www/lumen>
        Options FollowSymLinks
        # FileInfo: mod_rewrite. AuthConfig: Require and Auth*. Indexes: mod_expires. Options=Indexes: "Options -Indexes".
        AllowOverride AuthConfig FileInfo Indexes Options=Indexes
        Require all granted
        # Pass the Authorization header to PHP (application passwords, REST API).
        CGIPassAuth On
    </Directory>

    # Hand .php files that exist to the site's PHP-FPM pool.
    <FilesMatch "\.php$">
        <If "-f %{REQUEST_FILENAME}">
            SetHandler "proxy:unix:/run/php/lumen.sock|fcgi://localhost/"
        </If>
    </FilesMatch>
    DirectoryIndex index.php

    ErrorLog  ${APACHE_LOG_DIR}/lumenpress-error.log
    CustomLog ${APACHE_LOG_DIR}/lumenpress-access.log combined
</VirtualHost>

Points worth knowing about this file:

  • AllowOverride decides which directives .htaccess may contain. AllowOverride None (the default since Apache 2.3.9) makes Apache ignore .htaccess completely, which is the cause of "only the home page works" after a move to a new server. AllowOverride All works but also lets a compromised plugin write any directive. The list above allows what WordPress and the hardening rules need: FileInfo for RewriteRule, AuthConfig for Require and Basic authentication, Indexes for mod_expires, and Options=Indexes for Options -Indexes. Per-directory mod_rewrite also needs FollowSymLinks or SymLinksIfOwnerMatch.

  • CGIPassAuth (Apache 2.4.13 and later, directory context only) passes the Authorization header to FastCGI applications. The RewriteRule with [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] that WordPress writes into .htaccess does the same job for servers where it cannot be set. Without one of them, application passwords fail with 401 on every REST request.

  • The fastest Apache setup puts the WordPress rules in the <Directory> block and sets AllowOverride None, because Apache then stops looking for .htaccess in every directory of the path. WordPress can no longer update the rules itself, which is acceptable because the block is the same for every permalink structure.

The .htaccess file

The Lumen Press .htaccess keeps WordPress’s own block at the end and adds hardening and caching rules outside the markers, where WordPress does not overwrite them:

# /var/www/lumen/.htaccess - Lumen Press, Apache 2.4

# Deny configuration, logs and dot files.
<FilesMatch "^(wp-config\.php|debug\.log|readme\.html|license\.txt|\.ht.*|\.user\.ini)$">
    Require all denied
</FilesMatch>

# XML-RPC is not used by Lumen Press.
<Files "xmlrpc.php">
    Require all denied
</Files>

# The login screen: office network, or HTTP Basic credentials.
<Files "wp-login.php">
    AuthType Basic
    AuthName "Lumen Press staff"
    AuthUserFile /etc/apache2/lumenpress.htpasswd
    <RequireAny>
        Require ip 203.0.113.0/24
        Require valid-user
    </RequireAny>
</Files>

# Block the include-only files.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^wp-admin/includes/ - [F,L]
RewriteRule !^wp-includes/ - [S=3]
RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]
RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]
RewriteRule ^wp-includes/theme-compat/ - [F,L]
</IfModule>

# Long-lived caching for static assets; HTML is not cached by the browser.
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css                 "access plus 1 year"
    ExpiresByType text/javascript          "access plus 1 year"
    ExpiresByType image/webp               "access plus 1 year"
    ExpiresByType image/jpeg               "access plus 1 year"
    ExpiresByType font/woff2               "access plus 1 year"
</IfModule>

Options -Indexes

# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

# END WordPress

Uploads need a second file. WordPress never writes it, and without it a PHP file that reaches wp-content/uploads/ through a vulnerable plugin can be executed:

# /var/www/lumen/wp-content/uploads/.htaccess
<FilesMatch "\.(php|phar|phtml|php\d)$">
    Require all denied
</FilesMatch>

With both files in place, the test site answered 200 for /, /about/ and /wp-json/; 403 for /wp-config.php, /xmlrpc.php, /readme.html, /.htaccess, /wp-includes/version.php, /wp-admin/includes/file.php and a planted /wp-content/uploads/evil.php; 404 for /missing.php; and 401 for /wp-login.php (200 with the Basic credentials).

Notes on these rules:

  • Denying readme.html only reduces scanner noise; the version still shows in feeds and asset URLs, and updating is the real defense.

  • Blocking xmlrpc.php suits Lumen Press, which uses the REST API and application passwords; check first whether a plugin or client still needs XML-RPC. Its system.multicall method makes it a brute-force amplifier. The xmlrpc_enabled filter turns off only the authenticated methods and still loads WordPress for every request; a server rule stops the request before PHP.

  • Basic authentication or an IP allow list in front of wp-login.php stops password guessing before PHP runs (rate limiting is the nginx equivalent below). Renaming the login URL only reduces log noise. The full list is in Security Hardening.

  • mod_expires sets Cache-Control: max-age and Expires. Core, theme and plugin assets carry ?ver= query strings that change with each release, so a one-year lifetime is safe for them; HTML must not get one (page caching is in Performance and Caching).

  • php_value and php_flag lines in .htaccess work only with mod_php. Under PHP-FPM they cause a 500 Internal Server Error (Invalid command 'php_value'); use the pool’s php_admin_value or a .user.ini file instead (see Requirements and Hosting).

Apache 2.2 syntax and why it fails

Apache 2.4 (released in 2012) replaced the Order, Allow, Deny and Satisfy directives of mod_access with Require from mod_authz_core and mod_authz_host. The old directives survive only in mod_access_compat, which the upgrade guide describes as a transition aid. Mixing the two styles is discouraged because the merge order produces unexpected results. Several older books and many tutorials still show the 2.2 forms:

Apache 2.2 (outdated) Apache 2.4

Order deny,allow + Deny from all

Require all denied

Order allow,deny + Allow from all

Require all granted

Order deny,allow + Deny from all + Allow from 203.0.113.0/24

Require ip 203.0.113.0/24

Satisfy any (IP or password)

Several Require lines, or <RequireAny>

Satisfy all (IP and password)

<RequireAll>

On a 2.4 server a 2.2 block fails in one of two ways, both as 500 Internal Server Error for every request below that directory. These are the error-log lines from the test server:

# mod_access_compat loaded, but AllowOverride does not include Limit:
/var/www/lumen/old/.htaccess: order not allowed here
# mod_access_compat not loaded:
/var/www/lumen/old/.htaccess: Invalid command 'Order', perhaps misspelled or defined by a module not included in the server configuration

Require itself belongs to the AuthConfig override class, not Limit, so a virtual host that allows AuthConfig accepts the 2.4 rules and rejects the 2.2 ones. WordPress All-in-One For Dummies and Ultimate WordPress Handbook both protect wp-config.php and other files with 2.2 Order/Deny blocks; replace them with the 2.4 equivalents in the table.

Multisite and sub-directory rules on Apache

A Multisite network replaces the single-site block with a # BEGIN WordPress Multisite block that maps /kids/wp-admin/ and /noir/wp-login.php (the Lumen Kids and Lumen Noir imprints) back to the core files in the root. Network Setup prints the block to paste; the sub-directory and sub-domain variants, and the older pre-3.5 rules with ms-files.php, are on the handbook’s Apache page and in Multisite Networks. With the sub-directory block below the hardening rules, a test network answered 200 for /kids/ and /noir/wp-json/, redirected /kids/wp-admin to /kids/wp-admin/, and still denied /kids/wp-config.php and /kids/xmlrpc.php. A sub-domain network also needs a wildcard ServerAlias *.lumenpress.example and a wildcard DNS record.

The other layout that needs custom rules keeps core files in a sub-directory while visitors use the root, without changing either site address (Method I of "Giving WordPress Its Own Directory"; Method II, which changes the WordPress Address, is in Installation). The root .htaccess maps every path that does not exist in the root into wp/, and the WordPress block stays in wp/.htaccess:

# /var/www/lumen/.htaccess - core files live in /var/www/lumen/wp
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?lumenpress\.example$
RewriteCond %{REQUEST_URI} !^/wp/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /wp/$1
RewriteCond %{HTTP_HOST} ^(www\.)?lumenpress\.example$
RewriteRule ^(/)?$ wp/index.php [L]
</IfModule>

In the test, /, /sample-page/, /wp-login.php, /wp-json/ and /wp-includes/ assets all answered from wp/; only /wp-admin without a trailing slash is redirected (by mod_dir) to the visible /wp/wp-admin/.

nginx

The server block

nginx has no .htaccess and no PHP module. PHP runs in a PHP-FPM pool, and every rule lives in the server configuration, which nginx reads once at start or reload. The Lumen Press production configuration extends the short block in Requirements and Hosting with HTTP/3, hardening, login rate limiting and asset caching. Files in conf.d/ are included inside the http block, so upstream and limit_req_zone can be declared there:

# /etc/nginx/conf.d/lumenpress.conf (included inside the http { } block)

upstream lumen_php {
    server unix:/run/php/lumen.sock;
}

# 10 requests per minute per client address for the login form.
limit_req_zone $binary_remote_addr zone=lumen_login:10m rate=10r/m;

server {
    listen 80;
    listen [::]:80;
    server_name lumenpress.example www.lumenpress.example;
    return 301 https://lumenpress.example$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
    http2 on;
    http3 on;
    server_name lumenpress.example;

    ssl_certificate     /etc/ssl/lumenpress/fullchain.pem;
    ssl_certificate_key /etc/ssl/lumenpress/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:lumen_tls:10m;
    ssl_session_timeout 1d;

    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    add_header Strict-Transport-Security "max-age=31536000" always;

    root /var/www/lumen;
    index index.php;
    client_max_body_size 72m;

    include snippets/wordpress-restrictions.conf;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location = /wp-login.php {
        limit_req zone=lumen_login burst=5 nodelay;
        limit_req_status 429;
        include snippets/wordpress-php.conf;
    }

    location ~ \.php$ {
        include snippets/wordpress-php.conf;
    }

    location ~* \.(?:css|js|mjs|avif|webp|jpe?g|png|gif|ico|svg|woff2?)$ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
        access_log off;
        log_not_found off;
        try_files $uri =404;
    }
}

The PHP handling is shared by the two PHP locations:

# /etc/nginx/snippets/wordpress-php.conf
# Refuse scripts that do not exist instead of passing them to PHP.
try_files $uri =404;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;

include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO       $fastcgi_path_info;
fastcgi_index index.php;

fastcgi_read_timeout 120s;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_pass lumen_php;

And the restrictions, which mirror the Apache rules:

# /etc/nginx/snippets/wordpress-restrictions.conf
location = /favicon.ico { log_not_found off; access_log off; }
location = /robots.txt  { log_not_found off; access_log off; try_files $uri /index.php?$args; }

# Dot files (.htaccess, .git, .user.ini), except the ACME challenge directory.
location ~ /\.(?!well-known/) { deny all; }

# Configuration, logs and core files that need not be public.
location ~* ^/(?:wp-config\.php|wp-config-sample\.php|readme\.html|license\.txt)$ { deny all; }
location ~* ^/wp-content/debug\.log$ { deny all; }

# XML-RPC is not used by Lumen Press.
location = /xmlrpc.php { deny all; access_log off; }

# Never execute PHP from uploads.
location ~* ^/wp-content/uploads/.*\.(?:php\d*|phtml|phar)$ { deny all; }

# Include-only core files.
location ~ ^/wp-admin/includes/ { deny all; }
location ~ ^/wp-includes/[^/]+\.php$ { deny all; }
location ~ ^/wp-includes/theme-compat/ { deny all; }

nginx -t accepted the three files, and the test site answered with the same status codes as Apache. The login limit returned 429 once the burst was used up, /robots.txt reaches WordPress’s virtual robots file, and HTTP/3 worked with a client that supports it:

$ nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

$ for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -w '%{http_code} ' https://lumenpress.example/wp-login.php; done
200 200 200 200 200 200 429 429

$ curl -s --http3-only -o /dev/null -w '%{http_code} HTTP/%{http_version}\n' https://lumenpress.example/about/
200 HTTP/3

How the pieces work, and where they go wrong:

  • Location matching. Exact matches (=) win first. Regular-expression locations are then tried in the order they appear, and the first match wins. The restrictions are included before location ~ \.php$, so /wp-content/uploads/evil.php hits deny all before it could reach PHP. Including them after the PHP location would silently disable them for .php files.

  • try_files $uri $uri/ /index.php?$args is the whole permalink rule. Leaving out ?$args breaks searches, previews and any plugin that relies on query strings with pretty permalinks.

  • try_files $uri =404 in the PHP snippet returns 404 for scripts that do not exist. Without it, a request such as /wp-content/uploads/photo.jpg/x.php can make PHP execute the uploaded image when cgi.fix_pathinfo is 1, the PHP default. Set cgi.fix_pathinfo=0 in php.ini as a second layer.

  • SCRIPT_FILENAME is not in the stock fastcgi_params; when it is missing or wrong, PHP-FPM logs Primary script unknown and the browser shows File not found. The stock file does pass HTTPS $https if_not_empty, so is_ssl() works when nginx terminates TLS; behind a load balancer, see the HTTPS on line on the requirements page.

  • add_header inheritance. A location with its own add_header lines inherits none from the server level. That is why the asset location repeats the HSTS header. Combining expires 1y; with add_header Cache-Control produces two Cache-Control headers; the test showed max-age=31536000 and public, immutable as separate lines, so the configuration sets one complete header instead.

  • location = /wp-login.php is an exact match and does not fall through, so it includes the PHP snippet itself. Its key, $binary_remote_addr, is the address nginx sees; behind a CDN, restore the client address with set_real_ip_from and real_ip_header first.

Multisite and sub-directory rules on nginx

A sub-domain Multisite network needs no extra rules: add *.lumenpress.example to server_name, and use a wildcard certificate, which ACME certificate authorities issue only through the DNS-01 challenge. A sub-directory network needs one block at server level, before the locations, which strips the site prefix from core paths:

# /etc/nginx/snippets/wordpress-multisite-subdir.conf
# Sub-directory network: /kids/wp-admin/ and /noir/wp-login.php are served by the core files in the root.
if (!-e $request_filename) {
    rewrite /wp-admin$ $scheme://$host$request_uri/ permanent;
    rewrite ^(/[^/]+)?(/wp-.*) $2 last;
    rewrite ^(/[^/]+)?(/.*\.php) $2 last;
}

if is safe here because it contains only rewrite directives. With the snippet included after the restrictions, the test network served /kids/, /kids/wp-login.php, /noir/wp-json/ and /noir/wp-includes/css/dashicons.min.css with 200, redirected /kids/wp-admin to /kids/wp-admin/, and denied /kids/wp-config.php (the rewrite to /wp-config.php is matched again against the deny rule). The Nginx Helper plugin and the map for networks from before WordPress 3.5 are described on the handbook page.

For core files in wp/ served at the root (Method I, as above for Apache), both try_files lines look in wp/ second:

server {
    # listen, server_name and TLS as above
    root /var/www/lumen;
    index index.php;

    location / {
        try_files $uri /wp$uri /wp$uri/ /wp/index.php?$args;
    }

    location ~ \.php$ {
        try_files $uri /wp$uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass lumen_php;
    }
}

The first try_files has no $uri/: with it, / matched the root directory, which has no index.php, and the test answered 403 Forbidden. Without it, /, /sample-page/, /wp-admin/, /wp-login.php and /wp-json/ answered from wp/.

Page caching with fastcgi_cache, including the bypass rules for logged-in users, is in Performance and Caching.

PHP-FPM pools

PHP-FPM runs PHP as a pool of worker processes behind a socket. Apache (mod_proxy_fcgi), nginx and Caddy all use it. Each site should have its own pool, so that it runs as its own operating-system user, has its own limits, and cannot read another site’s files. The Lumen Press pool on Debian or Ubuntu:

; /etc/php/8.4/fpm/pool.d/lumen.conf - one pool per site
[lumen]
user = lumen
group = lumen

listen = /run/php/lumen.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

request_terminate_timeout = 120s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/lumen-slow.log

php_admin_value[error_log] = /var/log/php-fpm/lumen-error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 72M
php_admin_value[open_basedir] = /var/www/lumen:/tmp
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen

php-fpm8.4 -t checks the file (configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful), and systemctl reload php8.4-fpm applies it. The settings that matter:

Setting Meaning and guidance

user, group

The account PHP runs as. The site files should be owned by a deploy user and only wp-content/uploads (and caches) writable by this account; see File Permissions and Filesystem

listen, listen.owner, listen.mode

A Unix socket is faster than TCP on the same host. The web server’s user (www-data for Debian Apache and nginx packages, nginx for the nginx.org packages) must be able to open it, or every PHP request fails with 502 Bad Gateway and connect() to unix:/run/php/lumen.sock failed (13: Permission denied) in the nginx error log. Use TCP (127.0.0.1:9000, as on the requirements page) when PHP-FPM runs in another container or host

pm

dynamic keeps between min_spare and max_spare idle workers; ondemand starts workers only when requests arrive (good for many small sites); static keeps max_children always running (predictable, for a dedicated server)

pm.max_children

The most requests PHP can work on at once. Size it from memory: the RAM left for PHP divided by the resident size of one worker. The test workers used 39 to 44 MB each on a near-empty site; a production site with plugins often uses 60 to 120 MB, so measure with ps -o rss -C php-fpm8.4. The warning server reached pm.max_children setting in the PHP-FPM log means requests are queuing

pm.max_requests

Recycles a worker after this many requests, which contains slow memory leaks in plugins

request_terminate_timeout

Kills a request that runs longer. Keep it at or below the web server’s fastcgi_read_timeout or ProxyTimeout, otherwise the user gets 504 Gateway Timeout while PHP keeps working

request_slowlog_timeout, slowlog

Writes a PHP stack trace of requests slower than the limit, the cheapest way to find a slow plugin; see also PHP: Debugging and Profiling

php_admin_value, php_admin_flag

Per-pool php.ini settings that scripts cannot change with ini_set(). php_value would allow it. open_basedir and disable_functions limit what a compromised plugin can reach, but disable_functions can break plugins that shell out (some backup and image tools), so test first

OPcache is shared by all pools of one PHP-FPM master; its tuning is covered in PHP: OPcache, JIT and Performance.

HTTP/2, HTTP/3 and TLS

HTTP/2 and HTTP/3

HTTP/2 multiplexes many requests over one TLS connection, which suits WordPress pages that load dozens of CSS, script and image files. Browsers use it only over HTTPS. In Apache it is Protocols h2 http/1.1 with mod_http2, and it needs the event or worker MPM to be useful. In nginx 1.25.1 and later it is http2 on;; the older listen 443 ssl http2 parameter is deprecated and produces a warning.

HTTP/3 runs over QUIC on UDP. nginx supports it through ngx_http_v3_module, available since 1.25.0, still documented as experimental, and built into the official nginx.org packages and Docker image (nginx -V lists --with-http_v3_module). It needs a listen ... quic on the same port as TLS, reuseport on one of them, the UDP port open in the firewall, and an Alt-Svc header, because browsers always start over TCP and switch only after they see it. Apache httpd 2.4 has no HTTP/3 support; Apache sites get it from a CDN or proxy in front.

HTTP/2 Server Push is deprecated in RFC 9113 and was removed from Chrome and Edge in version 106, so advice to push WordPress assets no longer has any effect there. 103 Early Hints (Apache H2EarlyHints on) and <link rel="preload"> replace it.

TLS

  • Certificates. Use an ACME client (Certbot, acme.sh, or Apache’s mod_md) for free, automatically renewed certificates. The .well-known/acme-challenge/ path must stay reachable, which is why the nginx dot-file rule excludes .well-known.

  • Protocols. TLS 1.2 and 1.3 only (SSLProtocol -all +TLSv1.2 +TLSv1.3, ssl_protocols TLSv1.2 TLSv1.3). TLS 1.0 and 1.1 are deprecated by RFC 8996; a client limited to TLS 1.1 could not connect to the test servers. The Mozilla SSL Configuration Generator produces current cipher lists for both servers.

  • HSTS. Strict-Transport-Security tells browsers to use HTTPS only. Start with a short max-age, and add includeSubDomains only when every subdomain (including Multisite subsites) has a certificate. Other security headers are in Security Hardening.

  • WordPress side. The site addresses must start with https:// and PHP must see the request as secure; see Requirements and Hosting.

Errors and pitfalls

Symptom Usual cause Fix

Home page works, every other page is 404

Apache: .htaccess missing, or AllowOverride None; nginx: no try_files

Re-save Settings > Permalinks or restore the block; allow FileInfo; add try_files $uri $uri/ /index.php?$args;

500 for every request in a directory

A directive in .htaccess that the server does not allow (order not allowed here), an unloaded module (Invalid command 'Order'), or php_value under PHP-FPM

Read the error log, convert to 2.4 Require, move PHP settings to the pool or .user.ini

403 Forbidden on /

No index.php in DirectoryIndex/index, Options -Indexes with no index file, or a $uri/ that points nginx at an empty directory

Add index.php to the index list; check the document root

PHP source code downloaded or shown as text

PHP handler not configured for that host or location

Add the SetHandler/fastcgi_pass; never serve the document root before PHP works

502 Bad Gateway

PHP-FPM stopped, wrong socket path, or socket permissions

systemctl status php8.4-fpm; compare listen with fastcgi_pass/SetHandler; fix listen.owner

504 Gateway Timeout

Request longer than fastcgi_read_timeout or ProxyTimeout

Find the slow request in the slow log; align the timeouts

File not found. / Primary script unknown

SCRIPT_FILENAME missing or wrong; a non-existent .php passed to PHP

Set SCRIPT_FILENAME; add try_files $uri =404 or the Apache <If "-f ..."> test

REST requests with application passwords return 401

Authorization header not passed to PHP-FPM

CGIPassAuth On, or keep the HTTP_AUTHORIZATION rewrite line

Redirect loop on the dashboard

TLS ends at a proxy and PHP sees HTTP

Pass HTTPS on or translate X-Forwarded-Proto

413 Request Entity Too Large on upload

client_max_body_size (nginx) or LimitRequestBody (Apache) below post_max_size

Raise it to match PHP

Changes to nginx rules have no effect

Configuration not reloaded, or a later regex location wins

nginx -t && nginx -s reload; check the order of regex locations

Where older books are outdated

  • WordPress All-in-One For Dummies and Ultimate WordPress Handbook protect files with Apache 2.2 Order, Allow and Deny blocks. Apache 2.4 uses Require; the 2.2 directives fail with 500 unless mod_access_compat is loaded and the Limit override is allowed, and mixing the two styles is discouraged.

  • Ultimate WordPress Handbook recommends HTTP/2 Server Push. Major browsers removed it; HTTP/2 and HTTP/3 are still worth enabling, and 103 Early Hints replaces push.

  • Tutorials that pass every .php request to PHP without try_files $uri =404 allow uploaded files disguised as images to be executed, and renaming the login URL is no substitute for rate limiting at the server.