Nginx is fast by default. That is precisely why misconfigurations here are hard to spot: the server still feels reasonably quick, so nobody suspects the layer that is actually costing you.
Most production configurations are assembled from tutorials. Each snippet was correct in its original context. The combination frequently is not.
These are the ones we find most often, roughly in order of how much damage they do.
1. Caching a response that belongs to one person
This is not a performance bug. It is a data exposure, and it is the most serious mistake on this list by a wide margin.
If your cache key does not account for session state, Nginx will happily store the page generated for a logged-in user and serve it to the next anonymous visitor. On a store, that means someone else’s cart. On a membership site, someone else’s account page.
How to confirm you have it:
# Request a page as a logged-in user, then as an anonymous one
curl -sI -b "wordpress_logged_in_abc=someone" https://example.com/ | grep -i 'x-cache\|set-cookie'
curl -sI https://example.com/ | grep -i 'x-cache'
If the anonymous request returns a HIT on a page that was populated by an authenticated request, you have the bug.
The fix is an explicit skip, and it has to cover both cookies and paths:
map $http_cookie $skip_cache {
default 0;
"~*wordpress_logged_in_" 1;
"~*wp-postpass_" 1;
"~*comment_author_" 1;
"~*woocommerce_items_in_cart" 1;
"~*woocommerce_cart_hash" 1;
}
server {
# Paths that must never be cached regardless of cookies
location ~* ^/(wp-admin|wp-login\.php|wp-json|xmlrpc\.php|cart|checkout|my-account) {
set $skip_cache 1;
}
location ~ \.php$ {
fastcgi_cache_bypass $skip_cache; # do not serve from cache
fastcgi_no_cache $skip_cache; # do not store in cache
# ...
}
}
Both directives are needed and they do different things. fastcgi_cache_bypass stops Nginx serving
a cached copy. fastcgi_no_cache stops it storing this response. Setting only the first still
poisons the cache for everyone else.
2. Proxy and FastCGI buffers too small
This one is invisible, common, and costs real throughput.
When a response is larger than the configured buffers, Nginx writes the overflow to a temporary file on disk before sending it. Every request. Your web server is doing disk I/O to serve a page that should never have touched the filesystem.
WordPress pages with a large HTML payload exceed the default buffer sizes routinely.
How to confirm you have it:
# Add this temporarily to the http block
error_log /var/log/nginx/error.log warn;
grep -c 'buffered to a temporary file' /var/log/nginx/error.log
Any non-zero count means it is happening. On busy sites we have seen this logged on the majority of dynamic requests.
The fix:
fastcgi_buffering on;
fastcgi_buffer_size 32k; # for the response headers
fastcgi_buffers 16 32k; # 512k total for the body
fastcgi_busy_buffers_size 64k;
Size these to your actual responses rather than copying numbers. Measure first:
# Uncompressed size of a typical dynamic page
curl -s -H 'Accept-Encoding: identity' https://example.com/ | wc -c
3. Timeouts shorter than the application legitimately needs
A report that takes 45 seconds to generate, behind a 30 second timeout, returns a 504. The application gets blamed for a configuration limit.
The opposite error is just as common: timeouts set to 600 seconds “to be safe”, so a genuinely stuck request occupies a worker for ten minutes and the pool exhausts under any real load.
fastcgi_read_timeout 60s; # match the slowest legitimate request, plus headroom
fastcgi_connect_timeout 5s; # connecting to PHP-FPM should be near-instant
fastcgi_send_timeout 60s;
The right answer is to know your slowest legitimate operation. If that is genuinely several minutes, it should not be a synchronous web request at all: move it to a background job and let the browser poll.
4. PHP-FPM worker pool sized by guesswork
Nginx is almost never the bottleneck on a WordPress site. PHP-FPM usually is, and the 502 and 504 errors that result get attributed to Nginx because Nginx is what reports them.
How to confirm you have it:
# Enable the status page in your pool config, then
curl -s 'http://127.0.0.1/fpm-status' | grep -E 'listen queue|max children|active processes'
A non-zero listen queue or any value in max children reached means requests are waiting for a
worker.
Sizing it properly means measuring, not guessing:
# Average real memory per worker, in MB
ps --no-headers -o rss -C php-fpm8.2 | awk '{s+=$1; n++} END {print s/n/1024 " MB"}'
Divide the memory you are willing to give PHP by that figure. If a worker averages 80 MB and PHP
gets 2 GB, pm.max_children is about 25.
Setting it to 200 because the server has RAM is how a traffic spike triggers the OOM killer and takes down the database as well as the site.
5. Gzip applied to things that are already compressed
Compressing a JPEG, a MP4 or a .zip spends CPU to produce a file that is fractionally larger.
Compressing every response also means compressing responses too small for it to be worthwhile.
gzip on;
gzip_vary on;
gzip_min_length 1024; # below this, the overhead exceeds the saving
gzip_proxied any;
gzip_comp_level 5; # 9 costs noticeably more CPU for very little gain
gzip_types
text/plain text/css text/xml
application/json application/javascript application/xml+rss
image/svg+xml;
Note what is absent from that list: images other than SVG, video, and archives. text/html is always
compressed and does not need listing.
gzip_comp_level 9 is a common piece of copied configuration. The difference between level 5 and
level 9 is usually a couple of percent of file size for roughly double the CPU.
6. try_files in the wrong order
The canonical WordPress location block is small and easy to get subtly wrong:
location / {
try_files $uri $uri/ /index.php?$args;
}
Getting the order wrong, or omitting $uri/, produces 404s on directory-style URLs that are hard to
attribute because most of the site works.
A related trap is putting a location ~ \.php$ block above a more specific one you intended to
match. Nginx uses the first matching regex location in file order, not the most specific one.
This is different from prefix locations, where the longest match wins, and it catches people
regularly.
# See which location actually handled a request
nginx -T | grep -n 'location'
7. Redirect chains nobody measured
Each redirect is a round trip. Three of them before the page starts loading is common on sites that have been through a migration, a HTTPS move and a domain change.
curl -sIL -o /dev/null -w '%{num_redirects} redirects, %{time_total}s total\n' http://example.com
The classic chain is http://example.com to https://example.com to https://www.example.com to
https://www.example.com/. Collapse it into a single hop to the final destination:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
return 301 https://www.example.com$request_uri;
}
8. Rate limits set against imagined traffic
Rate limiting is worth having. Rate limiting tuned against a number somebody invented blocks real customers, generates complaints, and gets removed entirely, which leaves you with nothing.
Derive the limits from your actual logs:
# Requests per second per IP, top offenders, over your access log
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
Then apply the limit where abuse actually happens, which is usually the login endpoint rather than the whole site:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location = /wp-login.php {
limit_req zone=login burst=3 nodelay;
# ...
}
Use limit_req_status 429 so blocked clients get a meaningful status rather than a generic 503.
9. Static assets served without cache headers
Nginx will serve your CSS quickly. Without cache headers it will serve it again on every page view.
location ~* \.(?:css|js|woff2|jpg|jpeg|png|gif|svg|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
immutable requires that your assets are fingerprinted, meaning the filename changes when the
content does. If you serve style.css unversioned with a one year expiry, visitors will not see
your next CSS change for a year.
10. Not separating your time from the application’s time
This is less a misconfiguration than a missing measurement, and it is the one that would save the most wasted effort.
By default, Nginx logs how long it took to serve a request. It does not log how much of that was spent waiting for PHP. Without that split, you cannot tell whether a slow page is a web server problem or an application problem, and teams spend weeks optimizing the wrong layer.
log_format timing '$remote_addr - $status "$request" '
'request=$request_time upstream=$upstream_response_time '
'cache=$upstream_cache_status';
access_log /var/log/nginx/access.log timing;
Now the answer is in the log:
# Slowest requests by upstream time, which is the application's contribution
awk '{for(i=1;i<=NF;i++) if($i ~ /^upstream=/) print $i, $0}' /var/log/nginx/access.log \
| sort -rn -t= -k2 | head -20
If upstream is 1.4 seconds and request is 1.45, Nginx is contributing 50 milliseconds and every
hour spent on Nginx tuning is an hour not spent on the actual problem.
The pattern behind all of these
Nearly every item here comes from the same root cause: configuration copied from a context where it was correct, into one where nobody measured whether it still is.
Nginx does not complain about any of this. It will buffer to disk, compress your images, and serve a cached checkout page without a single warning. The only way to find these is to look, which is why the diagnostic command matters more than the fix in each section above.
Start with the log format change. Once you can see the split between your time and the application’s, most of the other decisions make themselves.