Somewhere around the third time I watched a WordPress site fall over during a traffic spike — despite having a perfectly reasonable server behind it — I stopped reaching for “just add more CPU” and started looking at FastCGI caching instead. The site was PHP-FPM behind Nginx, and every single request, including ones for the exact same unchanged blog post being hit thousands of times, was going all the way through PHP execution and a database query. Once I set up FastCGI cache to serve those repeated requests straight from Nginx’s memory instead, response times dropped from several hundred milliseconds to single-digit milliseconds, and PHP-FPM’s CPU usage during traffic spikes dropped dramatically. This guide covers exactly how I configure FastCGI caching, from the basics through cache invalidation and troubleshooting stale content.
What FastCGI Cache Actually Does
When Nginx proxies requests to a FastCGI backend (most commonly PHP-FPM), by default every single request re-executes the backend application — even if the exact same page was just served a second ago. FastCGI caching lets Nginx store the response from the backend and serve subsequent identical requests directly from cache, completely bypassing PHP-FPM (and whatever database queries it would have run) entirely.
This is conceptually similar to a full-page cache — for content that doesn’t change per-request (most blog posts, product pages, static-ish content), caching the whole rendered response is dramatically more efficient than regenerating it every time.
Requirements
- Nginx compiled with the
ngx_http_fastcgi_module(included by default in virtually all standard Nginx builds) - PHP-FPM (or another FastCGI-speaking backend) already configured and working with Nginx
- Root or sudo access
Step 1: Confirm Your Existing PHP-FPM + Nginx Setup
Before adding caching, make sure the base setup works. A typical PHP-FPM location block looks like this:
server {
listen 80;
server_name example.com;
root /var/www/example.com;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Confirm this works before adding caching on top — caching bugs are much harder to debug if you’re not sure whether the underlying proxy configuration is correct in the first place.
Step 2: Define the FastCGI Cache Path
Add this in the http block of nginx.conf (not inside a server block):
http {
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=FASTCGI_CACHE:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
}
Breaking this down:
/var/cache/nginx/fastcgi— the directory where cached responses are stored on disk.levels=1:2— organizes cache files into a two-level subdirectory hash structure, which avoids performance issues from having too many files in a single directory.keys_zone=FASTCGI_CACHE:100m— names this cache zoneFASTCGI_CACHEand allocates 100MB of shared memory for storing cache keys and metadata (not the actual cached content itself, which lives on disk).inactive=60m— cached items not accessed for 60 minutes are removed, even if they haven’t expired via their normal TTL.max_size=1g— caps total disk usage for this cache at 1GB; Nginx removes the least recently used entries once this limit is reached.fastcgi_cache_key— defines what uniquely identifies a cached entry. I include scheme, method, host, and URI so that HTTP vs HTTPS, GET vs POST, and different domains/paths are all cached separately.
Create the cache directory with correct ownership:
sudo mkdir -p /var/cache/nginx/fastcgi
sudo chown www-data:www-data /var/cache/nginx/fastcgi
Step 3: Enable Caching in the Server Block
server {
listen 80;
server_name example.com;
root /var/www/example.com;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache FASTCGI_CACHE;
fastcgi_cache_valid 200 60m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
add_header X-FastCGI-Cache $upstream_cache_status;
}
}
Key directives here:
fastcgi_cache FASTCGI_CACHE;— activates caching using the zone we defined earlier.fastcgi_cache_valid 200 60m;— cache successful (200) responses for 60 minutes.fastcgi_cache_valid 404 1m;— cache 404 responses too, but only briefly — this prevents repeated requests to nonexistent pages from hammering PHP-FPM, while not caching “not found” state for too long in case the content appears shortly after.fastcgi_cache_use_stale— serves a stale cached copy if the backend is erroring out or timing out, which is a great resilience feature: if PHP-FPM crashes or the database goes down temporarily, visitors still see cached content instead of an error page.add_header X-FastCGI-Cache $upstream_cache_status;— adds a response header showing cache status (HIT,MISS,BYPASS,EXPIRED,STALE), which is invaluable for debugging and verifying caching is actually working.
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Step 4: Excluding Requests That Should Never Be Cached
This is the step people skip and then get burned by — caching logged-in user sessions, admin panels, or POST requests (like login forms or checkout flows) can leak one user’s data to another or break functionality entirely.
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
set $skip_cache 0;
# Don't cache POST requests
if ($request_method = POST) {
set $skip_cache 1;
}
# Don't cache URLs with query strings (adjust as needed for your app)
if ($query_string != "") {
set $skip_cache 1;
}
# Don't cache admin, login, or cart pages
if ($request_uri ~* "/wp-admin/|/login|/cart|/checkout|/my-account") {
set $skip_cache 1;
}
# Don't cache for logged-in users (WordPress-style cookie check example)
if ($http_cookie ~* "comment_author|wordpress_logged_in|wp-postpass") {
set $skip_cache 1;
}
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache FASTCGI_CACHE;
fastcgi_cache_valid 200 60m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
add_header X-FastCGI-Cache $upstream_cache_status;
}
fastcgi_cache_bypass controls whether Nginx reads from cache for this request; fastcgi_no_cache controls whether it writes to cache. Setting both based on the same $skip_cache variable ensures sensitive or dynamic requests neither read stale cached data nor pollute the cache with content that shouldn’t be shared across users.
I’ve adapted the cookie-check pattern above from WordPress specifically — adjust the cookie names and excluded paths to match whatever application/framework you’re actually running.
Step 5: Testing the Cache Is Working
Request the same page twice and check the X-FastCGI-Cache header:
curl -I http://example.com/some-page/
curl -I http://example.com/some-page/
First request should show:
X-FastCGI-Cache: MISS
Second request (within the cache validity window) should show:
X-FastCGI-Cache: HIT
You can also directly inspect cache files on disk:
sudo ls -la /var/cache/nginx/fastcgi/
And measure the actual performance difference:
# First request (cache miss, hits PHP-FPM)
curl -o /dev/null -s -w "%{time_total}\n" http://example.com/some-page/
# Second request (cache hit, served by Nginx directly)
curl -o /dev/null -s -w "%{time_total}\n" http://example.com/some-page/
The difference is usually dramatic — I regularly see cached responses return in under 10ms versus 200-500ms+ for the uncached PHP-rendered version, depending on how database-heavy the page is.
Step 6: Cache Purging
Sixty-minute cache validity is fine for most content, but sometimes you need to invalidate the cache immediately — right after publishing a new blog post or updating a product price, for instance.
Manual purge (simplest approach):
sudo rm -rf /var/cache/nginx/fastcgi/*
This works but nukes the entire cache, which causes a temporary spike in backend load as everything gets regenerated at once. Fine for low-traffic sites; not ideal for busier ones.
Selective purging with ngx_cache_purge module:
For more surgical invalidation, install the third-party ngx_cache_purge module (available as a package on many distros, or compiled in if building from source):
sudo apt install libnginx-mod-http-cache-purge -y
Add a purge location:
location ~ /purge(/.*) {
allow 127.0.0.1;
deny all;
fastcgi_cache_purge FASTCGI_CACHE "$scheme$request_method$host$1";
}
Now you can purge a specific URL:
curl -X GET "http://127.0.0.1/purge/some-page/"
I integrate this into deployment scripts and CMS publish hooks so that content updates automatically purge just the affected cached pages, rather than the whole cache.
Step 7: Adding Cache Headers for Debugging and CDN Integration
If you’re also running a CDN in front of Nginx, make sure your caching headers are consistent so both layers agree on freshness:
location ~ \.php$ {
# ... existing fastcgi config ...
add_header X-FastCGI-Cache $upstream_cache_status;
add_header Cache-Control "public, max-age=300" always;
}
Be careful here — Cache-Control headers affect browser and CDN caching behavior, which is a separate (though related) concern from Nginx’s own FastCGI cache. I keep browser cache TTLs shorter than the Nginx FastCGI cache TTL in most cases, so that a purge at the Nginx layer is reflected to end users reasonably quickly rather than being masked by an aggressively long browser cache.
Troubleshooting Common Issues
Cache always shows MISS, never HIT — Check your fastcgi_cache_key isn’t inadvertently unique per request (for example, if it includes a session ID or timestamp variable by mistake), and confirm $skip_cache logic isn’t unconditionally set to 1 for the page you’re testing.
Logged-in users seeing other users’ cached content — This is the most serious FastCGI cache bug and almost always traces back to missing the cookie-based $skip_cache exclusion from Step 4. Audit this carefully before going live with caching on any site with user accounts.
Cache never expires despite fastcgi_cache_valid — Double check you don’t have inactive set longer than intended, and remember inactive and fastcgi_cache_valid serve different purposes — inactive is about removing unused entries regardless of validity, while fastcgi_cache_valid is about how long content is considered fresh.
Disk filling up in the cache directory — Your max_size setting either isn’t set or is set too high for your available disk space. Also confirm cache directory permissions are correct and Nginx isn’t failing to clean up old entries due to a permissions issue.
Purge requests return 404 — Confirm the ngx_cache_purge module is actually installed and loaded (nginx -V | grep cache_purge for statically compiled versions, or check load_module for dynamic ones), and that your purge location’s regex correctly matches the URL pattern you’re trying to purge.
Security Considerations
- Never cache authenticated or sensitive content — the cookie/session exclusions from Step 4 aren’t optional for any site with logins; skipping this is a real data-leak risk between users.
- Restrict access to the purge endpoint — as shown, only allow
127.0.0.1(or your specific trusted internal IPs/services) to trigger cache purges; an exposed purge endpoint lets anyone force-invalidate your cache repeatedly, which is itself a mild denial-of-service vector against your backend. - Be cautious caching pages that include CSRF tokens — if a cached page embeds a CSRF token meant to be unique per session, caching it breaks the security property that token was meant to provide. Exclude such pages from caching or restructure them to load tokens via a separate uncached AJAX call.
- Set correct file permissions on the cache directory to prevent other processes/users on the server from reading cached content that might include sensitive fragments.
Performance Tips
- Tune
fastcgi_cache_validper content type — static-ish content (blog posts, product pages) can have long TTLs; frequently changing content (a homepage with live inventory counts) needs shorter ones or exclusion entirely. - Use
fastcgi_cache_use_stale updatingso that when a cache entry is expiring and being regenerated, other concurrent requests are served the (slightly) stale version instead of all piling up waiting on the same slow regeneration — this specifically helps under high concurrency during cache expiry (“thundering herd” protection). - Warm the cache after purges for high-traffic pages by proactively requesting them right after a purge, rather than waiting for the first real visitor to trigger a slow cache-miss request.
- Monitor cache hit ratio over time; a low hit ratio suggests either overly aggressive exclusion rules or cache keys that are too granular (e.g., inadvertently including something request-unique in the cache key).
Real-World Use Cases
- WordPress and other CMS platforms under traffic spikes (a post going viral, a launch announcement) — this is the single highest-impact use case I’ve seen for FastCGI caching.
- E-commerce product listing pages that don’t need per-request freshness (inventory counts aside) — caching these dramatically reduces database load from repeated identical queries.
- News and content sites where the same articles get thousands of hits in a short window right after publishing.
- API responses from PHP-based backends for endpoints returning largely static or infrequently-changing data.
Best Practices I Follow
- Always exclude POST requests, admin paths, and authenticated-user cookies from caching before enabling it on any real site.
- Add the
X-FastCGI-Cachedebug header permanently — it costs nothing and is invaluable for ongoing troubleshooting. - Use
fastcgi_cache_use_staleto gracefully degrade during backend errors or slow responses rather than showing visitors an error page. - Set up selective purging (via
ngx_cache_purgeor similar) rather than relying solely on full-cache flushes for content-driven sites. - Restrict cache-purge endpoints to trusted internal sources only.
- Tune TTLs per content type rather than applying one blanket cache duration site-wide.
- Monitor cache hit ratio and disk usage as ongoing operational metrics, not just a set-and-forget configuration.
- Test thoroughly for cross-user data leakage before enabling caching on any site with user accounts or personalized content.
Wrapping Up
FastCGI caching is one of the highest-leverage performance changes I make on PHP-backed sites — the setup itself is a handful of directives, but the payoff (in reduced backend load, faster response times, and resilience during traffic spikes) is substantial. The part that actually requires care is correctly excluding dynamic and authenticated content from the cache; get that wrong and you risk serving one user’s data to another, which is a much worse outcome than the slow page load you were trying to fix in the first place. Start with detection via the X-FastCGI-Cache header, be conservative and thorough with your exclusion rules, and only then trust the cache to take real load off your backend.
