On day one of running WordPress on Nginx, I changed permalinks to /%postname%/. Every page except the homepage returned 404.
This is not unusual. WordPress documentation defaults to Apache .htaccess rules. Nginx doesn't use .htaccess, so 90% of newcomers hit this wall immediately after migrating.
This post covers exactly 5 real Nginx server block pitfalls I've encountered—each with the actual error and the fix.
Pitfall 1: Missing `/index.php` in try_files Breaks All Permalinks
**Symptom**: Homepage works fine. Every post, category, and tag page returns 404 Not Found (Nginx's default 404, not your WordPress theme's 404).
**Root cause**: The try_files directive controls how Nginx routes requests. WordPress uses a front controller pattern—all non-static requests should go to index.php. Without the /index.php fallback, Nginx can't find the file and returns 404 directly.
Broken config:
location / {
try_files $uri $uri/ =404; # ← Missing PHP fallback
}
Correct config:
location / {
try_files $uri $uri/ /index.php?$args; # ← Critical: fall back to index.php
}
Don't omit $args. Without it, WordPress query parameters (pagination ?page=2, search ?s=keyword) are silently dropped.
**Verify**: After the change, run sudo nginx -t && sudo systemctl reload nginx, then visit any post URL. It should render normally.
Pitfall 2: Incomplete FastCGI Cache Bypass Leaks Cached Pages to Logged-in Users
Symptom: You log into wp-admin but see a cached visitor page. You edit a post but the admin panel shows stale content. WooCommerce carts display other users' items.
**Root cause**: Nginx's fastcgi_cache ignores cookies by default and caches everything indiscriminately. WordPress identifies logged-in users via wordpress_logged_in_* cookies, but Nginx doesn't know about them.
**Fix**: Add a cache bypass map in your http {} or server {} block:
# Add in http {} block
map $http_cookie $skip_cache {
default 0;
~wordpress_logged_in 1;
~wp-postpass 1;
~woocommerce_items_in_cart 1;
}
# Use in the PHP location within server {} block
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# ... other fastcgi params
}
Key points:
- `fastcgi_cache_bypass` controls whether to skip reading from cache
- `fastcgi_no_cache` controls whether to skip writing to cache
- **Both must be set together**. Setting only one still causes problems.
WooCommerce extra care: Cart and checkout pages need additional exclusion:
map $uri $skip_cache_uri {
default 0;
~^/cart 1;
~^/checkout 1;
~^/my-account 1;
}
Then merge variables: set $do_skip $skip_cache$skip_cache_uri; and use $do_skip instead of $skip_cache.
Pitfall 3: Missing Security Headers Cause Security Scan Failures
Symptom: securityheaders.com scores you an F. Webmaster tools warn about missing security headers.
**Root cause**: Nginx adds zero security response headers by default. WordPress doesn't handle this either—it relies on .htaccess plugins under Apache. On Nginx, you must add them manually.
**Add all 6 core security headers at once** (in your server {} block):
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# HSTS — only enable after SSL is fully working!
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Control referrer information leakage
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Restrict browser features (camera/mic/location etc.)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# XSS protection (for legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;
⚠️ Three real traps here:
1. **The always keyword is mandatory**—without it, Nginx only returns these headers on 200/301 responses. On 404/500, they're omitted, and security scanners still dock points.
2. HSTS only belongs in the HTTPS server block—if you put it in the port 80 (HTTP) block, browsers ignore it.
3. **Don't blindly copy CSP (Content-Security-Policy)**—WordPress core + plugins + themes load tons of inline scripts. A simple script-src 'self' will white-screen your admin panel. Start with Content-Security-Policy-Report-Only to observe, then tighten gradually.
Pitfall 4: Unblocked xmlrpc.php Leads to Brute Force CPU Spikes
**Symptom**: Server CPU suddenly hits 100%. tail -f /var/log/nginx/access.log shows floods of POST /xmlrpc.php 200 requests.
Root cause: WordPress enables xmlrpc.php by default. Attackers use it for XML-RPC brute force—one request can try thousands of passwords, making it100x more efficient than traditional wp-login.php attacks.
Nginx block (add directly in your server block):
# Completely block xmlrpc.php
location = /xmlrpc.php {
deny all;
}
If you use Jetpack: Jetpack depends on xmlrpc.php. Allow only Jetpack IPs:
location = /xmlrpc.php {
allow 192.0.64.0/18; # Jetpack/Automattic IP range
deny all;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
**Verify**: curl -I https://yourdomain.com/xmlrpc.php should return 403 Forbidden.
Pitfall 5: PHP Upload Limits Silently Truncated at the Nginx Layer
**Symptom**: Uploading a theme or plugin shows "The uploaded file exceeds the upload_max_filesize directive in php.ini", even though you already changed php.ini to 64M.
**Root cause**: Nginx has its own request body size limit, defaulting to just 1MB. No matter how large you set PHP's upload_max_filesize and post_max_size, Nginx rejects the request before it reaches PHP.
Fix:
# In http {} or server {} block
client_max_body_size 64M;
The complete upload trifecta (both Nginx and PHP need changes):
# Nginx layer
client_max_body_size 64M;
; PHP layer (/etc/php/8.3/fpm/php.ini)
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
memory_limit = 256M
Restart PHP-FPM after changing PHP settings: sudo systemctl restart php8.3-fpm.
My Complete Server Block Reference
Based on spinupwp/wordpress-nginx (the best-maintained WordPress-Nginx template on GitHub) and my own battle scars, here's a production-ready single-site configuration skeleton:
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/example.com/public;
index index.php;
# SSL (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Upload limits
client_max_body_size 64M;
# WordPress permalinks
location / {
try_files $uri $uri/ /index.php?$args;
}
# Block xmlrpc
location = /xmlrpc.php {
deny all;
}
# Deny sensitive files
location ~ /\. { deny all; }
location ~* /wp-config.php { deny all; }
# PHP processing + FastCGI cache
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Static file caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 365d;
add_header Cache-Control "public, immutable";
access_log off;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
Version Reference (Verified June 2026)
| Component | Version |
|---|---|
| Nginx | 1.28.2 stable (includes CVE-2026-1642 fix) |
| PHP-FPM | 8.3 |
| WordPress | 6.8 |
| Ubuntu | 24.04 LTS |
Versions change. Configuration logic doesn't. Run nginx -v and php -v to confirm your own environment.
---
This post only covers Nginx server block pitfalls. If you're looking for WordPress database optimization traps, check out: WordPress wp_options Autoload Optimization.
If your WordPress runs in Docker, there are additional port mapping and network configuration traps—that's a whole different battlefield.
⚠️ This post contains affiliate links. If you purchase through these links, I may earn a small commission at no extra cost to you.
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: