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 |
|---|---|---|
|
|
|
|
|
Nothing is written; the Permalinks screen shows no rules |
|
|
Nothing is written, as for nginx |
|
|
|
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 |
|
|
LiteSpeed reads |
PHP |
|
PHP-FPM through FastCGI |
LSAPI ( |
Per-directory overrides |
Yes, |
No |
LiteSpeed yes; OpenLiteSpeed rewrite rules only |
HTTP/2 |
|
|
Yes |
HTTP/3 |
Not in httpd 2.4; put a CDN or proxy in front |
|
Yes |
Page 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
Source: Apache HTTP Server: HTTP/2 guide
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:
-
AllowOverridedecides which directives.htaccessmay contain.AllowOverride None(the default since Apache 2.3.9) makes Apache ignore.htaccesscompletely, which is the cause of "only the home page works" after a move to a new server.AllowOverride Allworks but also lets a compromised plugin write any directive. The list above allows what WordPress and the hardening rules need:FileInfoforRewriteRule,AuthConfigforRequireand Basic authentication,Indexesformod_expires, andOptions=IndexesforOptions -Indexes. Per-directorymod_rewritealso needsFollowSymLinksorSymLinksIfOwnerMatch. -
CGIPassAuth(Apache 2.4.13 and later, directory context only) passes theAuthorizationheader to FastCGI applications. TheRewriteRulewith[E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]that WordPress writes into.htaccessdoes the same job for servers where it cannot be set. Without one of them, application passwords fail with401on every REST request. -
The fastest Apache setup puts the WordPress rules in the
<Directory>block and setsAllowOverride None, because Apache then stops looking for.htaccessin 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.htmlonly reduces scanner noise; the version still shows in feeds and asset URLs, and updating is the real defense. -
Blocking
xmlrpc.phpsuits Lumen Press, which uses the REST API and application passwords; check first whether a plugin or client still needs XML-RPC. Itssystem.multicallmethod makes it a brute-force amplifier. Thexmlrpc_enabledfilter 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.phpstops 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_expiressetsCache-Control: max-ageandExpires. 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_valueandphp_flaglines in.htaccesswork only withmod_php. Under PHP-FPM they cause a500 Internal Server Error(Invalid command 'php_value'); use the pool’sphp_admin_valueor a.user.inifile 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 |
|---|---|
|
|
|
|
|
|
|
Several |
|
|
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 beforelocation ~ \.php$, so/wp-content/uploads/evil.phphitsdeny allbefore it could reach PHP. Including them after the PHP location would silently disable them for.phpfiles. -
try_files $uri $uri/ /index.php?$argsis the whole permalink rule. Leaving out?$argsbreaks searches, previews and any plugin that relies on query strings with pretty permalinks. -
try_files $uri =404in the PHP snippet returns404for scripts that do not exist. Without it, a request such as/wp-content/uploads/photo.jpg/x.phpcan make PHP execute the uploaded image whencgi.fix_pathinfois1, the PHP default. Setcgi.fix_pathinfo=0inphp.inias a second layer. -
SCRIPT_FILENAMEis not in the stockfastcgi_params; when it is missing or wrong, PHP-FPM logsPrimary script unknownand the browser showsFile not found.The stock file does passHTTPS $https if_not_empty, sois_ssl()works when nginx terminates TLS; behind a load balancer, see theHTTPS online on the requirements page. -
add_headerinheritance. A location with its ownadd_headerlines inherits none from the server level. That is why the asset location repeats the HSTS header. Combiningexpires 1y;withadd_header Cache-Controlproduces twoCache-Controlheaders; the test showedmax-age=31536000andpublic, immutableas separate lines, so the configuration sets one complete header instead. -
location = /wp-login.phpis 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 withset_real_ip_fromandreal_ip_headerfirst.
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
Source: PHP Manual: FPM configuration
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 |
|---|---|
|
The account PHP runs as. The site files should be owned by a deploy user and only |
|
A Unix socket is faster than TCP on the same host. The web server’s user ( |
|
|
|
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 |
|
Recycles a worker after this many requests, which contains slow memory leaks in plugins |
|
Kills a request that runs longer. Keep it at or below the web server’s |
|
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 |
|
Per-pool |
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-Securitytells browsers to use HTTPS only. Start with a shortmax-age, and addincludeSubDomainsonly 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 |
Apache: |
Re-save Settings > Permalinks or restore the block; allow |
|
A directive in |
Read the error log, convert to 2.4 |
|
No |
Add |
PHP source code downloaded or shown as text |
PHP handler not configured for that host or location |
Add the |
|
PHP-FPM stopped, wrong socket path, or socket permissions |
|
|
Request longer than |
Find the slow request in the slow log; align the timeouts |
|
|
Set |
REST requests with application passwords return |
|
|
Redirect loop on the dashboard |
TLS ends at a proxy and PHP sees HTTP |
Pass |
|
|
Raise it to match PHP |
Changes to nginx rules have no effect |
Configuration not reloaded, or a later regex location wins |
|
Where older books are outdated
-
WordPress All-in-One For Dummies and Ultimate WordPress Handbook protect files with Apache 2.2
Order,AllowandDenyblocks. Apache 2.4 usesRequire; the 2.2 directives fail with500unlessmod_access_compatis loaded and theLimitoverride 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 Hintsreplaces push. -
Tutorials that pass every
.phprequest to PHP withouttry_files $uri =404allow uploaded files disguised as images to be executed, and renaming the login URL is no substitute for rate limiting at the server.
The complete list is in Where Older Books and Tutorials Are Outdated.