You've already installed a caching plugin on your WordPress site, tuned OPcache, maybe even moved to PHP 8.3 — and pages still take 700ms to a full second to load on Nginx. If your server sits behind Nginx talking to PHP-FPM, there's a caching layer sitting unused between the two: Nginx's own FastCGI cache. Set it up right and you can serve full HTML pages straight from RAM, skipping PHP and MySQL entirely for anonymous visitors.
Symptom
Time to first byte stays high even with a WordPress caching plugin active. htop shows php-fpm workers spiking on every page view, not just on cache misses. Your plugin's page cache works for logged-out visitors in theory, but under any real traffic — a Facebook link, a WooCommerce sale, a bot crawl — PHP-FPM still gets hammered because the plugin's cache is a PHP-level check that still boots WordPress before deciding to serve a cached file.
Meanwhile Varnish or a full CDN feels like overkill for a single VPS, and LiteSpeed's built-in LSCache isn't available because you're running plain Nginx, not OpenLiteSpeed.
Cause
Most WordPress caching plugins (WP Super Cache, W3 Total Cache in disk mode) still route every request through PHP just to check "do I have a cached copy of this?" That check itself costs CPU and a filesystem stat. Nginx's fastcgi_cache module does the same job one layer earlier — Nginx checks its own cache zone in memory before the request ever reaches php-fpm. If a valid cached copy exists, PHP never starts, MySQL never gets queried, and the response ships in single-digit milliseconds.
Fix
You'll edit your Nginx server block for the site. This assumes a standard WordPress-on-Nginx-with-PHP-FPM setup (the kind SkyServer VPS plans use by default).
1. Define a cache zone in the main config
Add this inside the http {} block — usually in /etc/nginx/nginx.conf:
fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header updating http_500;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
keys_zone=WORDPRESS:100m reserves 100MB of RAM for cache keys (enough for roughly 800,000 cached URIs) and caps disk usage at 1GB via max_size. Adjust for your site's page count.
2. Add cache logic to your site's server block
Inside the location ~ \.php$ { ... } block that passes requests to php-fpm, add:
set $skip_cache 0;
# Don't cache POST requests or URLs with a query string
if ($request_method = POST) {
set $skip_cache 1;
}
if ($query_string != "") {
set $skip_cache 1;
}
# Don't cache admin, login, cart, or checkout pages
if ($request_uri ~* "/wp-admin/|/wp-login.php|/cart/|/checkout/|/my-account/") {
set $skip_cache 1;
}
# Don't cache for logged-in users or anyone who's commented
if ($http_cookie ~* "comment_author|wordpress_logged_in|wp-postpass|woocommerce_items_in_cart") {
set $skip_cache 1;
}
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 60m;
add_header X-FastCGI-Cache $upstream_cache_status;
That last line is your debugging lifeline — it adds a response header showing HIT, MISS, BYPASS, or EXPIRED so you can confirm caching is actually working.
3. Test and reload
nginx -t
systemctl reload nginx
Then check the header from the command line:
curl -I https://yourdomain.com/ | grep X-FastCGI-Cache
First request should show MISS, every request after that within the cache window should show HIT. If you see BYPASS on every request, one of your skip conditions is matching more broadly than intended — check for stray query strings from UTM tracking parameters, which will bypass cache on every ad click unless you strip them.
4. Purge cache on content changes
A stale cache showing yesterday's homepage to today's visitors is worse than no cache. The cleanest fix is the free Nginx Helper plugin from WordPress.org — set it to "Purge Cache" mode and point it at your cache path (/var/run/nginx-cache). It hooks into WordPress's save/publish/comment actions and clears only the relevant URLs automatically.
Without a plugin, you can purge manually:
rm -rf /var/run/nginx-cache/*
Prevention
- Keep the cache path on tmpfs or fast local SSD, not network storage —
fastcgi_cache_pathdoes a lot of small file writes. - If you run WooCommerce, double-check the skip-cache rules cover every dynamic page (cart, checkout, my-account, and any custom membership/dashboard URLs) — a cached checkout page is a real bug, not just an inconvenience.
- Set a sane
inactivevalue (how long an unused cache entry survives) so old, rarely-visited pages don't sit in RAM forever. - Monitor cache hit ratio with
$upstream_cache_statusin your access log format — if HIT rate stays low, something in your skip-cache logic is too aggressive. - Restart php-fpm and Nginx after any theme/plugin update that touches routing (permalinks, custom rewrite rules) — cached HTML can go stale in ways a purge plugin won't catch if the URL structure itself changed.
Quick Reference
| Symptom | Likely Cause | Fix |
|---|---|---|
| Every request shows MISS | Cache path not writable, or fastcgi_cache_valid too short | Check permissions on cache dir; confirm nginx user can write to it |
| Every request shows BYPASS | Overly broad skip_cache condition (e.g. stray query strings) | Tighten the $query_string and $http_cookie checks |
| Logged-in users see cached pages meant for guests | wordpress_logged_in cookie check missing | Add the cookie regex to skip_cache logic |
| Old content served after an edit | No purge mechanism wired up | Install Nginx Helper plugin in purge mode |
Frequently Asked Questions
Do I still need a WordPress caching plugin if I set up FastCGI cache?
Not for page caching — Nginx is doing that job now, and doing it faster since PHP never boots on a cache hit. You can keep a plugin around just for its minification, image optimization, or CDN integration features, with page caching disabled in the plugin's settings to avoid double work.
Will this work with WooCommerce?
Yes, but you must exclude cart, checkout, my-account, and any AJAX add-to-cart endpoints from caching, or customers will see stale stock counts and other people's session data. The skip-cache rules above cover the common WooCommerce URLs — extend them if your theme uses custom paths.
How is this different from Varnish?
Varnish sits in front of Nginx as a separate proxy and needs its own service, VCL config, and port juggling. FastCGI cache is built into Nginx itself — no extra service, no extra port, and for a single WordPress site on one VPS it gets you 90% of the speed benefit with a fraction of the setup complexity.
Why do logged-in users still see slow load times?
By design — the skip-cache rule for wordpress_logged_in ensures admins and logged-in members always get a fresh, dynamically generated page. That's correct behavior for a dashboard or personalized content; it only becomes a problem if your entire audience is logged in, in which case FastCGI cache won't help much and you should look at object caching (Redis) instead.
How do I know the cache is actually saving resources?
Watch php-fpm worker activity in htop during a traffic spike — with caching working, you'll see far fewer active workers than before, since most requests never reach PHP at all. You can also tail your Nginx access log with a custom log format that includes $upstream_cache_status to see the HIT/MISS ratio in real time.
