A client's WooCommerce store on a 4-core VPS started returning 502s at about 60 requests per second during a sale. The server was not out of CPU; PHP-FPM had simply run out of workers, and Nginx was passing every anonymous product page through to PHP anyway. Two hours of configuration later, the same box served 340 requests per second with headroom. These are the changes, in the order I applied them, with measurements.

Baseline and how to measure

# From a second machine in the same region. Test the real hot paths, not just /.
oha -z 30s -c 100 https://shop.example.com/
oha -z 30s -c 100 https://shop.example.com/product/sample-product/
oha -z 30s -c 100 "https://shop.example.com/?s=shirt"

# Watch the server while it runs
htop            # CPU per process: php-fpm vs nginx vs mysql
ss -s           # socket states; lots of TIME_WAIT hints at missing keepalive
tail -f /var/log/php8.3-fpm.log   # "server reached pm.max_children" = the 502 cause

Baseline for the store's product page: 58 req/s, p99 2.9 s, 4% errors at 100 concurrent connections.

Change 1: PHP-FPM pool sizing (58 → 112 req/s)

The default www.conf allows five children. Each PHP-FPM worker handles one request at a time, so five workers means the sixth concurrent request waits, and under load the queue overflows into 502s. Size the pool from memory:

# Average memory per php-fpm worker (after warming up under load)
ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1; n++} END {printf "%.0f MB avg over %d workers\n", sum/n/1024, n}'
# → 48 MB avg over 5 workers

# Memory available for PHP after OS, Nginx, MySQL and Redis:  8 GB - ~2.5 GB = ~5.5 GB
# max_children = 5500 / 48 ≈ 110. Use ~80 to leave headroom.
; /etc/php/8.3/fpm/pool.d/www.conf
pm = static                       ; predictable memory, no fork/kill churn under load
pm.max_children = 80
pm.max_requests = 1000            ; recycle workers to contain any slow leaks
pm.status_path = /fpm-status      ; expose metrics (restrict in Nginx to localhost)
request_terminate_timeout = 60s
slowlog = /var/log/php8.3-fpm-slow.log
request_slowlog_timeout = 3s      ; stack traces for anything over 3 s: finds the real bottlenecks

; Unix socket instead of TCP: less overhead on the same host
listen = /run/php/php8.3-fpm.sock
listen.backlog = 4096
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=30000
opcache.validate_timestamps=1
opcache.revalidate_freq=60        ; stat files once a minute, not on every request (0 in containers, see Docker post)
opcache.jit=tracing
opcache.jit_buffer_size=128M
realpath_cache_size=4096K
realpath_cache_ttl=600

Use pm = static on a dedicated box; use dynamic only when PHP shares the machine with something whose memory use varies. pm = ondemand is for low-traffic sites where you want workers to disappear between visits.

Change 2: Nginx core settings (112 → 140 req/s)

# /etc/nginx/nginx.conf
user www-data;
worker_processes auto;                 # one per core
worker_rlimit_nofile 65535;
pcre_jit on;

events {
    worker_connections 8192;
    multi_accept on;
    use epoll;
}

http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 30;
    keepalive_requests 1000;
    reset_timedout_connection on;
    client_body_timeout 12;
    send_timeout 10;
    types_hash_max_size 2048;
    server_tokens off;

    # Cache file descriptors and metadata for static files
    open_file_cache max=20000 inactive=60s;
    open_file_cache_valid 120s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;

    # Keep connections to PHP-FPM open instead of reconnecting per request
    upstream php {
        server unix:/run/php/php8.3-fpm.sock;
        keepalive 32;
    }

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

In the server block, the FastCGI settings that pair with upstream keepalive:

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass php;
    fastcgi_keep_conn on;                          # required for upstream keepalive to PHP-FPM
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_buffers 16 32k;
    fastcgi_buffer_size 64k;
    fastcgi_read_timeout 60;
}

Change 3: FastCGI cache (140 → 340 req/s)

This is the big one. For anonymous visitors, a WordPress product page is the same HTML for everyone; there is no reason to run PHP and MySQL for each request. Nginx can store the PHP response and serve it directly, bypassing PHP entirely, while logged-in users, carts and admin pages still go through.

# /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=WPCACHE:100m inactive=24h max_size=2g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout updating invalid_header http_500 http_503;   # serve stale if PHP is struggling
fastcgi_cache_background_update on;
fastcgi_cache_lock on;                                                             # stampede protection
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;

# Decide, per request, whether to skip the cache
map $request_uri $skip_uri {
    default 0;
    ~^/(wp-admin|wp-login\.php|wp-json|cart|checkout|my-account|xmlrpc\.php) 1;
    ~\?(?!utm_|fbclid|gclid)                                                  1;   # real query strings bypass; marketing params don't
    ~^/feed 1;
}
map $http_cookie $skip_cookie {
    default 0;
    ~wordpress_logged_in|wp-postpass|woocommerce_cart_hash|woocommerce_items_in_cart|wp_woocommerce_session|comment_author 1;
}
map "$request_method$skip_uri$skip_cookie" $skip_cache {
    default 1;
    "GET00" 0;
    "HEAD00" 0;
}
# in the server block, inside location ~ \.php$
fastcgi_cache WPCACHE;
fastcgi_cache_valid 200 301 302 1h;
fastcgi_cache_valid 404 5m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-FastCGI-Cache $upstream_cache_status;      # HIT / MISS / BYPASS / STALE: check it in devtools

# Purge endpoint for the WordPress plugin (Nginx Helper) or a custom hook; restrict to localhost
location ~ /purge(/.*) {
    allow 127.0.0.1;
    deny all;
    fastcgi_cache_purge WPCACHE "$scheme$request_method$host$1";   # needs ngx_cache_purge module
}

The map blocks are where all the correctness lives. Cache a page for a logged-in user once and they will see someone else's account page; cache a cart page and the customer sees stale items. The three maps together say: cache only GET/HEAD, only on public paths, only without a session cookie. Install the "Nginx Helper" plugin (or the PHP purge hook from my Cloudflare Workers post pointed at /purge) so publishing content clears the relevant URLs.

After this change: 340 req/s, p99 180 ms, 0 errors on the product page, with PHP-FPM sitting at 8% CPU. The cache hit rate for the store settled at 91%.

Change 4: Compression and protocols

# Brotli (ngx_brotli module; nginx.org packages include it as a dynamic module from 1.25+ on some distros)
brotli on;
brotli_comp_level 5;
brotli_static on;                 # serve pre-compressed .br files from the build if present
brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml font/woff2;

# Gzip fallback for older clients
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    listen 443 quic reuseport;          # HTTP/3, if built with --with-http_v3_module
    listen [::]:443 quic reuseport;
    add_header Alt-Svc 'h3=":443"; ma=86400';

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:20m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_early_data on;                  # 0-RTT resumption
}

Brotli at level 5 made the store's main CSS 14% smaller than gzip 6 for roughly the same CPU cost. For pre-built assets, brotli_static lets you compress once at build time at level 11 and serve the file with no runtime cost at all; Vite can emit .br files with a plugin, which fits the Tailwind v4 + Vite setup.

Change 5: Static assets and image handling

location ~* \.(css|js|woff2|svg|png|jpg|jpeg|gif|webp|avif|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";       # safe when filenames are hashed
    access_log off;
    try_files $uri =404;
}

# Serve WebP/AVIF automatically if the file exists next to the original
map $http_accept $img_ext {
    default "";
    ~image/avif ".avif";
    ~image/webp ".webp";
}
location ~* ^(?<base>.+)\.(jpe?g|png)$ {
    add_header Vary Accept;
    try_files $base$img_ext $uri =404;
}

Results summary

Stepreq/s (product page)p99Errors
Baseline (defaults)582.9 s4%
+ PHP-FPM static pool, opcache, JIT1121.1 s0%
+ Nginx core, upstream keepalive, open_file_cache140820 ms0%
+ FastCGI cache (91% hit rate)340180 ms0%
+ Brotli, HTTP/2, HTTP/3345 (bandwidth −38%)170 ms0%

The bandwidth drop is what matters for mobile users on slow connections; requests per second is what matters for the sale.

What this does not fix

The FastCGI cache only helps anonymous traffic. Logged-in users, carts and checkout still run PHP on every request; for those, the fixes are an object cache, database indexes and fewer plugins. And nothing here helps if the theme itself renders in 800 ms; the PHP slow log will tell you, and a leaner theme is often the real answer.

The full config for this stack, including the Docker version, is in Dockerizing PHP and WordPress. For the layer in front of Nginx, see edge caching with Cloudflare Workers; the two caches stack, and together they let a small VPS survive almost any traffic a content or commerce site will see.