WordPress on NGINX is a popular combination — but a default installation leaves a lot of performance on the table. Default PHP-FPM settings are tuned for a shared hosting environment with dozens of sites; a dedicated server running one WordPress site can go much faster. Default NGINX config doesn’t enable FastCGI caching, compression, or proper static file handling. Default WordPress sends no cache headers, causing browsers to re-download the same assets on every visit.
This guide covers the complete WordPress + NGINX + PHP-FPM stack on Debian and Ubuntu: server block configuration, PHP-FPM pool tuning, FastCGI caching for anonymous traffic, object caching with Redis, security hardening, and performance verification. Uses the optimised myguard packages throughout.
NGINX Server Block for WordPress
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; # Security: don't execute non-existent PHP files
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;
}
PHP-FPM Pool Tuning
Edit /etc/php/8.4/fpm/pool.d/www.conf. The default settings are for shared hosting. Tune for a dedicated server:
[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
How to calculate pm.max_children: check your average PHP process memory usage (ps --no-headers -o rss -C php-fpm8.4 | awk '{sum+=$1} END {print sum/NR/1024 " MB"}'), then divide available RAM (minus OS and NGINX overhead) by that number. For a 2GB VPS: (2048MB – 400MB overhead) / ~80MB per process = ~20 workers.
FastCGI Cache: Serve WordPress Pages Without PHP
For unauthenticated visitors reading blog posts and pages, NGINX can cache the full PHP response and serve subsequent requests without touching PHP at all. A cached page serves in ~1ms instead of ~80ms. For a blog with most traffic being anonymous readers, this is transformative.
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; # Cache 200 responses for 60 minutes
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-FastCGI-Cache $upstream_cache_status;
}
}
}
Create the cache directory:
mkdir -p /var/cache/nginx/wordpress
chown www-data:www-data /var/cache/nginx/wordpress
To purge the cache when WordPress publishes or updates a post, install the Nginx Cache or Nginx Helper WordPress plugin, or use the myguard cache purge module.
Object Cache with Redis
WordPress’s default object cache is per-request — database queries are cached in memory but the cache dies at the end of each request. Redis persists the object cache between requests, dramatically reducing database load:
apt-get install redis-server php8.4-redis
# Verify Redis is running
redis-cli ping # Should return PONG
In WordPress, install the Redis Object Cache plugin, or add to wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);
With Redis object cache, a typical WordPress page that runs 30 database queries on the first load runs 2–3 on subsequent loads (just cache miss checks). For WooCommerce sites with heavy product catalog queries, the improvement is even more dramatic.
Compression for WordPress
Enable Brotli and gzip for all text content:
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 Brotli module: apt-get install libnginx-mod-http-brotli
Security Hardening Checklist
Beyond the server block config above:
- PHP-Snuffleupagus:
apt-get install php8.4-snuffleupagus— blocks dangerous PHP functions at the interpreter level, protects against webshells even if a plugin is compromised - ModSecurity WAF:
apt-get install libnginx-mod-http-modsecurity— blocks SQLi, XSS, and scanner traffic before it reaches PHP - Rate limiting on /wp-login.php: 5 req/min per IP blocks credential stuffing
- Block xmlrpc.php: Unless you use Jetpack or mobile app editing, add
location = /xmlrpc.php { deny all; } - File upload validation: Snuffleupagus upload validation rejects PHP files disguised as images
Performance Verification
# 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/
# Look for 'Requests per second' and 'Time per request'
# Check Brotli is serving
curl -H 'Accept-Encoding: br' -I https://example.com/ | grep Content-Encoding
# Should show: Content-Encoding: br
Frequently Asked Questions
Related Posts
- NGINX Brotli Compression Module — detailed Brotli setup and pre-compression for static assets
- PHP-Snuffleupagus: Harden PHP-FPM — interpreter-level PHP security essential for WordPress
- NGINX ModSecurity WAF Setup — 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 — A+ SSL Labs config for your WordPress HTTPS
- How to Add the myguard APT Repository — where the optimised NGINX packages come from