If your PHP-FPM workers are pegged and TTFB is creeping past 500ms on pages that haven't changed all week, you don't necessarily need to bolt on Varnish. Nginx already ships with a caching layer built for exactly this - fastcgi_cache - and it skips the extra daemon, the extra port, and the extra VCL file you'd need to learn for Varnish. Here's how to turn it on safely, without serving stale carts or logged-in admin pages to the wrong visitor.
Symptom: PHP-FPM Maxed Out, Same Pages Rebuilt Every Time
Run top or htop during a traffic spike and you'll see a wall of php-fpm processes at or near 100% CPU. systemctl status php8.2-fpm (swap in your version) shows the pool hitting pm.max_children and queuing requests. Meanwhile your Nginx access log shows the same ten URLs - homepage, a popular blog post, a product category page - getting hit hundreds of times a minute, and every single one is going all the way through to PHP, MySQL, and back.
You bump pm.max_children, add RAM, maybe move to LiteSpeed - and it helps a little, then the next spike does the same thing. The problem isn't capacity, it's that nothing is remembering the answer it already computed ten seconds ago.
Cause: No Response Cache Sitting in Front of PHP-FPM
By default, Nginx is just a very fast traffic cop - it hands every PHP request straight to php-fpm over a Unix socket or TCP port and waits for a fresh response. There's no memory of "I already rendered this exact page for the last visitor." Object caches like Redis or OPcache help with parts of the PHP execution, but they don't stop the full page render from happening on every hit.
Varnish solves this by sitting in front of everything as its own reverse proxy, but that means a second daemon, a second port to manage, and rewriting your SSL termination path around it. For a single web server stack where Nginx is already terminating TLS and talking to PHP-FPM, fastcgi_cache gets you most of the same win with zero extra moving parts - it's a module already compiled into stock Nginx.
The Fix: Turn On fastcgi_cache
1. Define the cache path (http block)
Open your main config, usually /etc/nginx/nginx.conf, and add this inside the http {} block, not inside a server block:
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=WPCACHE:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header http_500;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;Create the directory and hand it to the Nginx user first, or Nginx will fail to start:
sudo mkdir -p /var/cache/nginx/fastcgi
sudo chown -R www-data:www-data /var/cache/nginx/fastcgi2. Turn it on in the server block
Inside your site's server {} block, in the location ~ \.php$ block where PHP-FPM is already being called, add:
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache WPCACHE;
fastcgi_cache_valid 200 60m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-FastCGI-Cache $upstream_cache_status;
}Test the config and reload before moving on: sudo nginx -t && sudo systemctl reload nginx.
3. Don't cache logged-in users, carts, or admin pages
This is the step people skip, and it's the one that gets a WooCommerce store owner locked out of seeing their own cart contents, or a logged-in editor served someone else's cached admin bar. Define $skip_cache above your server {} block:
set $skip_cache 0;
if ($request_method = POST) {
set $skip_cache 1;
}
if ($query_string != "") {
set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|/cart/|/checkout/|/my-account/") {
set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|woocommerce_items_in_cart|wordpress_logged_in") {
set $skip_cache 1;
}Adjust the cookie and URI patterns to match your CMS - the pattern is the same for any PHP app: skip the cache whenever there's a session cookie, a cart, or a form submission involved.
4. Purge the cache on content updates
A 60-minute fastcgi_cache_valid means edits can take up to an hour to show unless you purge manually. Three practical options, easiest first:
- WordPress: install the free Nginx Helper plugin and point it at your cache path - it purges automatically on publish/update.
- Manual purge:
sudo rm -rf /var/cache/nginx/fastcgi/*after a deploy or bulk content change. - Selective purge: install the
ngx_cache_purgemodule (or use OpenResty/Nginx Plus) and hit a/purge/<url>location to clear one page without wiping the whole cache.
Prevention: Verify It's Actually Caching, and Keep It From Filling the Disk
Check the response header on a public page - you should see MISS on the first load and HIT on the second:
curl -I https://yourdomain.com/ | grep X-FastCGI-CacheIf it stays on BYPASS or MISS every time, one of your $skip_cache conditions is probably matching every request - check for a stray cookie being set on every page load (some analytics or A/B testing scripts do this).
The cache directory isn't self-limiting beyond max_size, so watch disk usage on a busy site: du -sh /var/cache/nginx/fastcgi. The inactive=60m setting already evicts entries nobody's requested in an hour, but on a VPS with a small disk, keep max_size conservative (500m-1g) rather than leaving it unbounded.
| Directive | What it does |
|---|---|
fastcgi_cache_path | Where cached responses live on disk and how big the cache zone can grow |
fastcgi_cache_valid | How long to keep a cached response before treating it as stale |
fastcgi_cache_bypass | Skip reading from cache for matching requests (e.g. logged-in users) |
fastcgi_no_cache | Skip writing to cache for matching requests |
fastcgi_cache_use_stale | Serve a stale cached copy if PHP-FPM errors out or times out |
When You Still Want Varnish Instead
fastcgi_cache is the right call when Nginx is your only web server and you want one less service to patch and monitor. Reach for Varnish instead if you're running Apache (fastcgi_cache is Nginx-only), need advanced VCL logic like device-based variations, or you're already load-balancing across multiple backend app servers and want the cache to sit independently in front of all of them.
Quick Checklist
- Cache path defined in the
http {}block, directory owned by the Nginx/PHP-FPM user fastcgi_cacheand cache-control directives added to your PHPlocationblock$skip_cachelogic excluding POST requests, query strings, admin/cart/checkout URLs, and session cookies- A purge method in place - plugin, manual, or selective module
X-FastCGI-Cacheheader confirmed asHITon repeat requests viacurl -I