How to Set Up an Nginx FastCGI Cache for WordPress on a Small Server
To cache a WordPress site in Nginx, define a fastcgi_cache_path zone, switch the cache on inside the location that passes PHP to PHP-FPM, and bypass it for logged-in users, admin pages, POST requests and query strings. Add an X-Cache header so you can see every HIT, MISS and BYPASS, test the configuration with nginx -t before each reload, and clear the cache after you publish. That gives anonymous readers a page straight from disk instead of a PHP and MySQL round trip. This guide builds it step by step and shows real measurements from a 1 GB server.
The commands assume Ubuntu 24.04 with Nginx 1.24 and PHP-FPM, and a WordPress blog served from a sub-path such as /blog. If your WordPress lives at the site root, the same rules apply to your main PHP location. It pairs well with the guides on PHP-FPM workers for limited RAM and optimizing WordPress on a 1 GB server.
What a page cache does and does not do
Without a cache, every visit to a post runs PHP, loads WordPress and queries MySQL to build the same HTML the previous visitor received. A FastCGI cache lets Nginx store that HTML the first time and answer the following requests itself, without waking PHP-FPM at all.
It is only safe for pages that look the same for everyone. A logged-in editor, a commenter with a saved name, a search results page and the admin area must never come from a shared cache, so most of the work in this guide is deciding what to skip. It also does not make a slow page fast on the first request. The first visitor after a purge still pays the full PHP cost.
Step 1: Check your current setup
Find the version and the location block that hands PHP to PHP-FPM. That block is where the cache switches on:
nginx -v
sudo nginx -T 2>/dev/null | grep -n "fastcgi_cache|fastcgi_pass"
sudo nginx -T 2>/dev/null | grep -n "configuration file"
If the second command already shows fastcgi_cache lines, you have a cache and should adjust it instead of adding a second one. Note which file holds the PHP location. On Ubuntu that is usually a file in /etc/nginx/sites-available/, linked from sites-enabled, and you edit the real file.
Step 2: Create the cache zone and the skip rules
Create the cache directory, owned by the user your Nginx workers run as (www-data on Ubuntu):
sudo mkdir -p /var/cache/nginx/blog
sudo chown www-data:www-data /var/cache/nginx/blog
Then put the zone and the rules in their own file under /etc/nginx/conf.d/. Nginx includes that folder at the http level, which is where these directives must live. Ubuntu’s default nginx.conf already includes it:
fastcgi_cache_path /var/cache/nginx/blog levels=1:2 keys_zone=SHBLOG:10m max_size=100m inactive=60m use_temp_path=off;
map $request_method $sh_skip_method { default 1; GET 0; HEAD 0; }
map $request_uri $sh_skip_uri {
default 0;
~*/wp-admin 1;
~*wp-login.php 1;
~*/wp-json 1;
~*/wp-cron 1;
~*/feed 1;
~*sitemap 1;
~*preview= 1;
}
map $http_cookie $sh_skip_cookie {
default 0;
~*wordpress_logged_in 1;
~*wordpress_sec 1;
~*comment_author 1;
~*wp-postpass 1;
}
map $query_string $sh_skip_qs { default 1; "" 0; }
map "$sh_skip_method$sh_skip_uri$sh_skip_cookie$sh_skip_qs" $sh_skip { "0000" 0; default 1; }
Here is what each part does:
keys_zone=SHBLOG:10mreserves 10 MB of shared memory for cache keys. That is far more than a small blog needs.max_size=100mcaps the cache on disk, and Nginx evicts the least recently used entries beyond it. On a small server, a cap you chose beats an unbounded directory.- The four
mapblocks each answer one question with 0 (cache is fine) or 1 (skip): is it a GET or HEAD, is it an admin, login, API, feed, sitemap or preview URL, does the visitor carry a login or comment cookie, and is there a query string. The last map combines them, so a page is cached only when all four say 0. - Query strings are skipped entirely. That covers search (
?s=) and tracking parameters. It is the conservative choice, and it also gives you a way to measure the uncached path, as shown later.
Step 3: Turn the cache on in the PHP location
Inside the location that contains fastcgi_pass, add these lines after the existing fastcgi_param and include lines:
fastcgi_cache SHBLOG;
fastcgi_cache_key "$scheme$host$request_uri";
fastcgi_cache_valid 200 10m;
fastcgi_cache_valid 301 302 404 1m;
fastcgi_cache_bypass $sh_skip;
fastcgi_no_cache $sh_skip;
fastcgi_cache_lock on;
fastcgi_cache_use_stale error timeout updating;
add_header X-Cache $upstream_cache_status always;
The choices behind them:
- Ten minutes for a 200 is short enough that an edit shows up quickly, and one minute for redirects and 404s keeps a mistake from lingering.
fastcgi_cache_bypassandfastcgi_no_cachetogether. Bypass means “do not answer from the cache”, and no-cache means “do not store the answer”. You need both, or a logged-in page could be stored and shown to someone else.- The key has no request method, so a HEAD request and a GET request share one entry.
fastcgi_cache_lock onmakes simultaneous requests for an uncached page wait for the first one instead of all hitting PHP together.fastcgi_cache_use_staleserves an old copy if PHP-FPM errors, times out or is regenerating the page.
Warning. In Nginx, add_header directives in a location replace every header inherited from the server block. As soon as you add X-Cache to the PHP location, your security headers (X-Frame-Options, X-Content-Type-Options and the rest) silently disappear from those responses. Repeat them in the same location.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
Nginx also honours the response headers PHP sends. If WordPress or a plugin returned Set-Cookie, Cache-Control: no-cache or a past Expires for anonymous pages, Nginx would refuse to cache them. In the test below, anonymous pages sent none of those, so no fastcgi_ignore_headers was needed. Check your own headers before adding that directive, because it overrides a signal the application set on purpose.
Step 4: Reload with a safety net
A syntax error in a live Nginx file can take every site on the server down at the next reload, so back up first and reload only if the test passes. Replace the path with your real site file:
S=/etc/nginx/sites-available/yoursite
BK=~/nginx-site.bak-$(date +%F-%H%M)
sudo cp -a $S $BK
# ...edit $S and create the conf.d file here...
if sudo nginx -t; then
sudo systemctl reload nginx && echo "RELOADED"
else
echo "TEST FAILED: restoring"
sudo cp -a $BK $S
sudo rm -f /etc/nginx/conf.d/blog-cache.conf
sudo nginx -t
fi
Use whatever name you gave the conf.d file in the rm line. A reload keeps existing connections alive, so it is safe on a running site.
Step 5: Confirm HIT, MISS and BYPASS
Request the same page twice and read the X-Cache header. The first should be a MISS and the second a HIT:
U=https://example.com/blog/some-post/
curl -s -o /dev/null -D - $U | grep -i '^x-cache'
curl -s -o /dev/null -D - $U | grep -i '^x-cache'
Then check that the skip rules work. Each of these should print BYPASS:
curl -s -o /dev/null -D - -H 'Cookie: wordpress_logged_in_x=1' $U | grep -i '^x-cache'
curl -s -o /dev/null -D - "https://example.com/blog/?s=test" | grep -i '^x-cache'
curl -s -o /dev/null -D - "https://example.com/blog/wp-sitemap.xml" | grep -i '^x-cache'
Finally, confirm the security headers survived on a cached page. This should print 5:
curl -sI $U | grep -ci 'x-frame-options|x-content-type-options|x-xss-protection|referrer-policy|permissions-policy'
If you use a CDN such as Cloudflare in front, run these checks from the server itself, so they measure Nginx and not the CDN:
curl -s --resolve example.com:443:127.0.0.1 -o /dev/null -D - $U | grep -i '^x-cache'
The --resolve option sends the request to the local Nginx, and X-Cache is generated by that Nginx. Cloudflare passes it through untouched.
What this looked like on a real 1 GB server
These are measurements from the SiteHarbour blog, running on Nginx 1.24.0 and Ubuntu with 911 MiB of usable RAM, alongside a Laravel application and MySQL. The requests came from the server itself through --resolve, so the network and the CDN are not in the timings. Each request opened a new connection, so the times include the TCP and TLS setup. Treat the figures as an example of what to read, not as targets.
| Request | Time to first byte |
|---|---|
| Uncached (query string bypasses the cache), five runs | 76 to 97 ms; four of five between 76 and 78.5 ms |
| First request after a purge (MISS) | 79 ms |
| Cached (HIT), five runs | 34.2 to 35.6 ms |
| Check | Result |
|---|---|
| Logged-in cookie | BYPASS |
| Search query string | BYPASS |
| Sitemap | BYPASS |
| Security headers on a cached page | 5 of 5 present |
| Cache on disk after the test | 72 KB in 1 file |
| PHP-FPM workers | 3, averaging about 64 MB resident each |
| Memory while idle | 585 MB used, 325 MB available of 911 MB |
What the numbers say:
- The cache saves about 43 ms per request, roughly 2.2 times faster. The uncached page was already quick, so the absolute gain is modest.
- A MISS costs the same as no cache. The 79 ms MISS matches the uncached runs, so the cache adds no measurable penalty when it misses.
- The cached time is mostly connection setup. Since each request opened a new TLS connection, part of the 34 ms is the handshake, not the cache lookup. A browser reusing a connection would see less.
- The bigger benefit is capacity, and it was not load-tested. A cached request needs no PHP worker and no database query. With only three workers at about 64 MB each, that should matter during a traffic spike, but that is reasoning from the design, not a measurement. The resident figure counts shared memory per worker, so it overstates the true cost.
- The cache stays tiny. One page took 72 KB, so the 100 MB cap is a safety limit, not something a small blog approaches.
Step 6: Clear the cache after you publish
A page can stay in the cache for up to ten minutes, so a new post or an edit may not appear straight away. Purge the cache after every publish:
sudo find /var/cache/nginx/blog -type f -delete
It is harmless, because the cache refills on the next visit. If you publish through a script, put this line at the end of it. Nginx Open Source has no built-in purge command for individual URLs, and deleting the files is the simple substitute.
Tip. After a PHP or theme change, also reload PHP-FPM if your OPcache is set to validate_timestamps=0. Clearing the Nginx cache alone will not make PHP re-read changed files.
Common mistakes
- Caching logged-in pages. Without the cookie bypass, an editor’s admin bar or a commenter’s name can be served to strangers. Always keep
fastcgi_cache_bypassandfastcgi_no_cachetogether. - Losing security headers. Adding
add_headerin a location wipes the inherited ones. Verify the count after every change. - Enabling “Cache Everything” on a CDN for the same path. A CDN rule like that can bypass your cookie rules and cache pages for logged-in users. Let Nginx do the HTML caching and let the CDN handle static files.
- Testing through a CDN and blaming Nginx. Use
--resolveto hit the server directly. - Reloading without
nginx -t. One typo can break every site on the machine. - Forgetting to purge. If a fixed typo does not appear, wait ten minutes or clear the cache.
- Ignoring upstream cache headers. Forcing Nginx to cache pages that WordPress marked as private can leak personal content.
When page caching is not enough
A page cache helps anonymous readers only. If your traffic is mostly logged-in users, a shop with carts and checkouts, or a membership site, most requests bypass the cache and the cost stays in PHP and MySQL. If the server swaps under ordinary load, the fix is more memory, not more caching. You can move to managed WordPress hosting, where page caching and the database stack are already tuned, or to a dedicated server for sustained high traffic.
Frequently asked questions
Is a FastCGI cache better than a WordPress caching plugin?
They solve the same problem at different layers. The Nginx cache answers before PHP starts, so it is lighter on a small server, while a plugin gives you settings inside the WordPress admin. Running both is usually redundant, so pick one and measure.
Will it break comments or forms?
POST requests are never cached, and a visitor with a comment cookie bypasses the cache. A cached page can still show an out-of-date comment count for up to ten minutes.
How long should I cache pages?
Ten minutes is a cautious start for a blog that publishes a few times a week. If you rarely edit posts, you can raise it, as long as you purge after each publish.
Does the cache work over HTTP/2 and HTTPS?
Yes. The key includes the scheme, host and URI, and the protocol between the visitor and Nginx does not matter for the stored copy.
How do I know it is working?
Read the X-Cache header. HIT means Nginx answered from the cache, MISS means PHP built the page and stored it, and BYPASS means a skip rule applied.
The short version: define a capped cache zone, skip everything personal or dynamic, turn it on in the PHP location, keep your security headers, test with nginx -t before reloading, read X-Cache to confirm behaviour, and purge after you publish.