A default PHP-FPM pool ships tuned for shared hosting that juggles dozens of sites on one box. You are not running dozens of sites. You are running one WordPress install on a server you actually control, and those defaults are leaving most of your hardware idle while your visitors wait. Getting the WordPress NGINX configuration right is the difference between a page that renders in a millisecond and one that drags PHP out of bed for every single request.
Here is the whole stack, top to bottom: the NGINX server block, PHP-FPM pool tuning, FastCGI caching so anonymous readers never touch PHP, a Redis object cache, Brotli compression, and the security hardening that keeps the script kiddies out. It runs on Debian and Ubuntu, and it uses the optimised myguard packages throughout. Back up your existing config before you touch any of this. You know why.

The NGINX server block for WordPress
This is where every request lands first. Get the WordPress NGINX configuration wrong here and nothing downstream matters. The block below terminates TLS, serves static assets straight off disk with year-long cache headers, hands PHP to the FPM socket, and slams the door on the files attackers always poke at.
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
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 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /var/www/example.com;
index index.php;
# Serve static assets directly with long cache headers
location ~* \.(js|css|png|jpg|jpeg|webp|svg|woff2|woff|ttf|ico|pdf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
log_not_found off;
access_log off;
}
# WordPress permalink rewriting
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP via PHP-FPM
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300s;
}
# Block direct access to sensitive files
location ~* /\.(?:git|env|htaccess|htpasswd)$ { deny all; }
location ~* /(wp-config\.php|xmlrpc\.php)$ { deny all; }
location = /wp-config.php { deny all; }
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
That try_files $uri $uri/ /index.php?$args line is WordPress permalinks in one breath. Miss it and every pretty URL 404s, and you spend an afternoon convinced your database is broken. It isn’t. It never was.
PHP-FPM pool tuning
PHP-FPM tuning is the heart of any serious WordPress NGINX configuration. Edit /etc/php/8.4/fpm/pool.d/www.conf. The shipped values assume you are one tenant of many. You are the landlord now. Tune for it.
[www]
user = www-data
group = www-data
; Use Unix socket (faster than TCP for local connections)
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
; Dynamic process management
pm = dynamic
pm.max_children = 20 ; Max concurrent PHP processes
pm.start_servers = 5 ; Start with 5 workers
pm.min_spare_servers = 3 ; Keep at least 3 idle
pm.max_spare_servers = 8 ; Keep at most 8 idle
pm.max_requests = 500 ; Recycle workers after 500 requests (prevents memory leaks)
; Slower request logging for debugging
slowlog = /var/log/php/www-slow.log
request_slowlog_timeout = 5s
; PHP settings for WordPress
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
php_admin_value[max_execution_time] = 120
php_admin_value[error_log] = /var/log/php/www-error.log
php_admin_flag[log_errors] = on
The number people get wrong is pm.max_children. Set it too high and a traffic spike spawns more PHP workers than you have RAM, the kernel’s OOM killer wakes up, and it shoots one of your workers in the head mid-request. Set it too low and requests queue while memory sits empty. Measure first: check your average PHP process RSS, then divide available RAM (minus what NGINX, Redis, and the OS need) by that number. For a 2 GB VPS: (2048 MB − 400 MB overhead) / ~80 MB per process ≈ 20 workers. The official PHP-FPM configuration reference documents every directive if you want to go deeper.
FastCGI cache: serve WordPress pages without PHP
Most of your traffic is anonymous. Someone clicks a link from a search result, reads one post, leaves. For that visitor, NGINX can cache the entire rendered page and serve it again without ever starting PHP. A cached page goes out in roughly 1 ms. The uncached version, the one that boots WordPress and runs thirty queries, takes around 80 ms. Do that math across a few thousand visits and the FastCGI cache stops being an optimisation and starts being the whole point.
http {
# Cache zone: 256MB storage
fastcgi_cache_path /var/cache/nginx/wordpress
levels=1:2 keys_zone=wp_cache:100m
max_size=256m inactive=60m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
# Cache settings per request
set $skip_cache 0;
# Don't cache POST requests
if ($request_method = POST) { set $skip_cache 1; }
# Don't cache URLs with query strings (search results, paginated)
if ($query_string != "") { set $skip_cache 1; }
# Don't cache logged-in users or cart pages
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart") {
set $skip_cache 1;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_cache wp_cache;
fastcgi_cache_valid 200 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-FastCGI-Cache $upstream_cache_status;
}
}
}
The cookie regex is the part that saves you from a 3 a.m. page. Forget it and a logged-in editor gets served another reader’s cached page, or worse, a logged-out visitor gets handed an editor’s admin bar. Cache the public, skip the private. Purge on publish with a plugin like Nginx Helper, or the myguard cache purge module.
Object cache with Redis
WordPress caches database query results in memory, then throws that cache away at the end of every request. Next request, it does the work again. Redis keeps the object cache alive between requests, so the thirtieth identical query of the day costs nothing.
apt-get install redis-server php8.4-redis
# Verify Redis is running
redis-cli ping # Should return PONG
Then install the Redis Object Cache plugin, or wire it by hand in wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);
A page that fired 30 database queries cold will run 2 or 3 once Redis is warm, the rest served from memory. This is the single biggest win in the whole WordPress NGINX configuration for query-heavy sites. On a WooCommerce site grinding through product-catalog queries, the drop is even steeper.
Compression for WordPress
Compression rounds out the WordPress NGINX configuration. Enable Brotli and gzip for text. Brotli wins on HTML and CSS; gzip is the fallback for the handful of clients that still don’t speak Brotli.
http {
brotli on;
brotli_comp_level 6;
brotli_types text/html text/css application/javascript application/json image/svg+xml;
gzip on;
gzip_comp_level 6;
gzip_vary on;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
}
Install the module first: apt-get install libnginx-mod-http-brotli. For the deep dive on compression levels and pre-compression, see the NGINX Brotli compression guide.
Security hardening checklist
A WordPress NGINX configuration that performs but leaks is a liability. The server block already deny-lists the obvious files. Beyond that, defence in depth:
- PHP-Snuffleupagus (
apt-get install php8.4-snuffleupagus) blocks dangerous PHP functions at the interpreter level, so a compromised plugin can’t drop a webshell. - ModSecurity WAF (
apt-get install libnginx-mod-http-modsecurity) stops SQLi, XSS, and scanner noise before it reaches PHP. - Rate-limit
/wp-login.php. Five requests a minute per IP and credential stuffing dies on the doorstep. - Block
xmlrpc.phpunless you genuinely use Jetpack or the mobile app:location = /xmlrpc.php { deny all; } - Snuffleupagus upload validation rejects PHP files wearing a
.jpgcostume.
Performance verification
Don’t trust it because the config looks right. Prove it.
# Test FastCGI cache is working
curl -I https://example.com/ | grep X-FastCGI-Cache
# First request: X-FastCGI-Cache: MISS
# Second request: X-FastCGI-Cache: HIT
# Check PHP-FPM worker status
curl http://127.0.0.1/fpm-status # Add status page in pool config
# Benchmark with ab (Apache Bench)
ab -n 1000 -c 10 https://example.com/
# Check Brotli is serving
curl -H "Accept-Encoding: br" -I https://example.com/ | grep Content-Encoding
First curl says MISS, second says HIT. That HIT is PHP getting the morning off. If it stays MISS forever, your cookie regex is matching something it shouldn’t, or the cache directory isn’t writable. Either way, your WordPress NGINX configuration isn’t done until that second request reads HIT.
Do I need a WordPress caching plugin if I use FastCGI cache?
For anonymous traffic, the FastCGI cache at the NGINX level beats any plugin cache because it serves pages without starting PHP at all. You still want a plugin to handle purging when you publish or update a post. Nginx Helper, WP Rocket, or W3 Total Cache all do the purge job.
What is the right pm.max_children value for my server?
Measure your average PHP-FPM process size (ps aux | grep php-fpm), then divide available RAM by it. For a 4 GB server running only WordPress, expect 30 to 50 workers. Leave 20 to 30 percent of RAM for NGINX, Redis, MySQL, and the OS.
Should I use a TCP or Unix socket for PHP-FPM?
Use a Unix socket when PHP-FPM and NGINX share a server. It skips the network stack and shaves 5 to 15 percent off per-request latency. Use TCP (127.0.0.1:9000) only when they live on different machines.
Does FastCGI cache work with WooCommerce?
For anonymous shoppers browsing the catalogue, yes, very well. For logged-in users with a cart, the skip_cache cookie logic keeps their session fresh. Also exclude the cart, checkout, and account pages; the Nginx Helper plugin handles those exclusions for you.
Is Angie a better choice than NGINX for this WordPress NGINX configuration?
For WordPress performance the two are identical, the config in this guide drops straight onto Angie. Angie’s edge is server management: native ACME with no Certbot, and a JSON monitoring API. If you want Let’s Encrypt without the Certbot dance, Angie is worth it. WordPress itself won’t notice the difference.
Related posts
- NGINX Brotli Compression Module: Brotli setup and pre-compression for static assets.
- PHP-Snuffleupagus: Harden PHP-FPM: interpreter-level PHP security for WordPress.
- NGINX ModSecurity WAF Setup: the HTTP-layer WAF to pair with PHP hardening.
- NGINX Rate Limiting Guide: protect wp-login.php and xmlrpc.php from brute force.
- TLS Configuration for NGINX: the A+ SSL Labs config for your WordPress HTTPS.
- How to Add the myguard APT Repository: where the optimised NGINX packages come from.