{"id":3295,"date":"2026-09-30T11:24:33","date_gmt":"2026-09-30T11:24:33","guid":{"rendered":"https:\/\/siteharbour.com\/blog\/?p=3295"},"modified":"2026-09-30T11:24:34","modified_gmt":"2026-09-30T11:24:34","slug":"nginx-fastcgi-cache-wordpress","status":"publish","type":"post","link":"https:\/\/siteharbour.com\/blog\/nginx-fastcgi-cache-wordpress\/","title":{"rendered":"How to Set Up an Nginx FastCGI Cache for WordPress on a Small Server"},"content":{"rendered":"<p>To cache a WordPress site in Nginx, define a <code>fastcgi_cache_path<\/code> 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 <code>X-Cache<\/code> header so you can see every HIT, MISS and BYPASS, test the configuration with <code>nginx -t<\/code> 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.<\/p>\n<p>The commands assume Ubuntu 24.04 with Nginx 1.24 and PHP-FPM, and a WordPress blog served from a sub-path such as <code>\/blog<\/code>. If your WordPress lives at the site root, the same rules apply to your main PHP location. It pairs well with the guides on <a href=\"\/blog\/optimize-php-fpm-workers-limited-ram\/\">PHP-FPM workers for limited RAM<\/a> and <a href=\"\/blog\/optimize-wordpress-1gb-ram-server\/\">optimizing WordPress on a 1 GB server<\/a>.<\/p>\n<h2>What a page cache does and does not do<\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<h2>Step 1: Check your current setup<\/h2>\n<p>Find the version and the location block that hands PHP to PHP-FPM. That block is where the cache switches on:<\/p>\n<pre><code>nginx -v\nsudo nginx -T 2&gt;\/dev\/null | grep -n \"fastcgi_cache|fastcgi_pass\"\nsudo nginx -T 2&gt;\/dev\/null | grep -n \"configuration file\"<\/code><\/pre>\n<p>If the second command already shows <code>fastcgi_cache<\/code> 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 <code>\/etc\/nginx\/sites-available\/<\/code>, linked from <code>sites-enabled<\/code>, and you edit the real file.<\/p>\n<h2>Step 2: Create the cache zone and the skip rules<\/h2>\n<p>Create the cache directory, owned by the user your Nginx workers run as (<code>www-data<\/code> on Ubuntu):<\/p>\n<pre><code>sudo mkdir -p \/var\/cache\/nginx\/blog\nsudo chown www-data:www-data \/var\/cache\/nginx\/blog<\/code><\/pre>\n<p>Then put the zone and the rules in their own file under <code>\/etc\/nginx\/conf.d\/<\/code>. Nginx includes that folder at the <code>http<\/code> level, which is where these directives must live. Ubuntu&#8217;s default <code>nginx.conf<\/code> already includes it:<\/p>\n<pre><code>fastcgi_cache_path \/var\/cache\/nginx\/blog levels=1:2 keys_zone=SHBLOG:10m max_size=100m inactive=60m use_temp_path=off;\n\nmap $request_method $sh_skip_method { default 1; GET 0; HEAD 0; }\nmap $request_uri $sh_skip_uri {\n    default 0;\n    ~*\/wp-admin 1;\n    ~*wp-login.php 1;\n    ~*\/wp-json 1;\n    ~*\/wp-cron 1;\n    ~*\/feed 1;\n    ~*sitemap 1;\n    ~*preview= 1;\n}\nmap $http_cookie $sh_skip_cookie {\n    default 0;\n    ~*wordpress_logged_in 1;\n    ~*wordpress_sec 1;\n    ~*comment_author 1;\n    ~*wp-postpass 1;\n}\nmap $query_string $sh_skip_qs { default 1; \"\" 0; }\nmap \"$sh_skip_method$sh_skip_uri$sh_skip_cookie$sh_skip_qs\" $sh_skip { \"0000\" 0; default 1; }<\/code><\/pre>\n<p>Here is what each part does:<\/p>\n<ul>\n<li><strong><code>keys_zone=SHBLOG:10m<\/code><\/strong> reserves 10 MB of shared memory for cache keys. That is far more than a small blog needs.<\/li>\n<li><strong><code>max_size=100m<\/code><\/strong> caps 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.<\/li>\n<li><strong>The four <code>map<\/code> blocks<\/strong> 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.<\/li>\n<li><strong>Query strings are skipped entirely.<\/strong> That covers search (<code>?s=<\/code>) and tracking parameters. It is the conservative choice, and it also gives you a way to measure the uncached path, as shown later.<\/li>\n<\/ul>\n<h2>Step 3: Turn the cache on in the PHP location<\/h2>\n<p>Inside the location that contains <code>fastcgi_pass<\/code>, add these lines after the existing <code>fastcgi_param<\/code> and <code>include<\/code> lines:<\/p>\n<pre><code>fastcgi_cache SHBLOG;\nfastcgi_cache_key \"$scheme$host$request_uri\";\nfastcgi_cache_valid 200 10m;\nfastcgi_cache_valid 301 302 404 1m;\nfastcgi_cache_bypass $sh_skip;\nfastcgi_no_cache $sh_skip;\nfastcgi_cache_lock on;\nfastcgi_cache_use_stale error timeout updating;\nadd_header X-Cache $upstream_cache_status always;<\/code><\/pre>\n<p>The choices behind them:<\/p>\n<ul>\n<li><strong>Ten minutes for a 200<\/strong> is short enough that an edit shows up quickly, and one minute for redirects and 404s keeps a mistake from lingering.<\/li>\n<li><strong><code>fastcgi_cache_bypass<\/code> and <code>fastcgi_no_cache<\/code> together.<\/strong> Bypass means &#8220;do not answer from the cache&#8221;, and no-cache means &#8220;do not store the answer&#8221;. You need both, or a logged-in page could be stored and shown to someone else.<\/li>\n<li><strong>The key has no request method,<\/strong> so a HEAD request and a GET request share one entry.<\/li>\n<li><strong><code>fastcgi_cache_lock on<\/code><\/strong> makes simultaneous requests for an uncached page wait for the first one instead of all hitting PHP together.<\/li>\n<li><strong><code>fastcgi_cache_use_stale<\/code><\/strong> serves an old copy if PHP-FPM errors, times out or is regenerating the page.<\/li>\n<\/ul>\n<div class=\"callout callout-warning\">\n<p><strong>Warning.<\/strong> In Nginx, <code>add_header<\/code> directives in a location replace every header inherited from the server block. As soon as you add <code>X-Cache<\/code> to the PHP location, your security headers (<code>X-Frame-Options<\/code>, <code>X-Content-Type-Options<\/code> and the rest) silently disappear from those responses. Repeat them in the same location.<\/p>\n<\/div>\n<pre><code>add_header X-Frame-Options \"SAMEORIGIN\" always;\nadd_header X-Content-Type-Options \"nosniff\" always;\nadd_header X-XSS-Protection \"1; mode=block\" always;\nadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\nadd_header Permissions-Policy \"geolocation=(), microphone=()\" always;<\/code><\/pre>\n<p>Nginx also honours the response headers PHP sends. If WordPress or a plugin returned <code>Set-Cookie<\/code>, <code>Cache-Control: no-cache<\/code> or a past <code>Expires<\/code> for anonymous pages, Nginx would refuse to cache them. In the test below, anonymous pages sent none of those, so no <code>fastcgi_ignore_headers<\/code> was needed. Check your own headers before adding that directive, because it overrides a signal the application set on purpose.<\/p>\n<h2>Step 4: Reload with a safety net<\/h2>\n<p>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:<\/p>\n<pre><code>S=\/etc\/nginx\/sites-available\/yoursite\nBK=~\/nginx-site.bak-$(date +%F-%H%M)\nsudo cp -a $S $BK\n\n# ...edit $S and create the conf.d file here...\n\nif sudo nginx -t; then\n  sudo systemctl reload nginx &amp;&amp; echo \"RELOADED\"\nelse\n  echo \"TEST FAILED: restoring\"\n  sudo cp -a $BK $S\n  sudo rm -f \/etc\/nginx\/conf.d\/blog-cache.conf\n  sudo nginx -t\nfi<\/code><\/pre>\n<p>Use whatever name you gave the <code>conf.d<\/code> file in the <code>rm<\/code> line. A reload keeps existing connections alive, so it is safe on a running site.<\/p>\n<h2>Step 5: Confirm HIT, MISS and BYPASS<\/h2>\n<p>Request the same page twice and read the <code>X-Cache<\/code> header. The first should be a MISS and the second a HIT:<\/p>\n<pre><code>U=https:\/\/example.com\/blog\/some-post\/\ncurl -s -o \/dev\/null -D - $U | grep -i '^x-cache'\ncurl -s -o \/dev\/null -D - $U | grep -i '^x-cache'<\/code><\/pre>\n<p>Then check that the skip rules work. Each of these should print BYPASS:<\/p>\n<pre><code>curl -s -o \/dev\/null -D - -H 'Cookie: wordpress_logged_in_x=1' $U | grep -i '^x-cache'\ncurl -s -o \/dev\/null -D - \"https:\/\/example.com\/blog\/?s=test\" | grep -i '^x-cache'\ncurl -s -o \/dev\/null -D - \"https:\/\/example.com\/blog\/wp-sitemap.xml\" | grep -i '^x-cache'<\/code><\/pre>\n<p>Finally, confirm the security headers survived on a cached page. This should print 5:<\/p>\n<pre><code>curl -sI $U | grep -ci 'x-frame-options|x-content-type-options|x-xss-protection|referrer-policy|permissions-policy'<\/code><\/pre>\n<p>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:<\/p>\n<pre><code>curl -s --resolve example.com:443:127.0.0.1 -o \/dev\/null -D - $U | grep -i '^x-cache'<\/code><\/pre>\n<p>The <code>--resolve<\/code> option sends the request to the local Nginx, and <code>X-Cache<\/code> is generated by that Nginx. Cloudflare passes it through untouched.<\/p>\n<h2>What this looked like on a real 1 GB server<\/h2>\n<p>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 <code>--resolve<\/code>, 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.<\/p>\n<table>\n<thead>\n<tr>\n<th>Request<\/th>\n<th>Time to first byte<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Uncached (query string bypasses the cache), five runs<\/td>\n<td>76 to 97 ms; four of five between 76 and 78.5 ms<\/td>\n<\/tr>\n<tr>\n<td>First request after a purge (MISS)<\/td>\n<td>79 ms<\/td>\n<\/tr>\n<tr>\n<td>Cached (HIT), five runs<\/td>\n<td>34.2 to 35.6 ms<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<table>\n<thead>\n<tr>\n<th>Check<\/th>\n<th>Result<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Logged-in cookie<\/td>\n<td>BYPASS<\/td>\n<\/tr>\n<tr>\n<td>Search query string<\/td>\n<td>BYPASS<\/td>\n<\/tr>\n<tr>\n<td>Sitemap<\/td>\n<td>BYPASS<\/td>\n<\/tr>\n<tr>\n<td>Security headers on a cached page<\/td>\n<td>5 of 5 present<\/td>\n<\/tr>\n<tr>\n<td>Cache on disk after the test<\/td>\n<td>72 KB in 1 file<\/td>\n<\/tr>\n<tr>\n<td>PHP-FPM workers<\/td>\n<td>3, averaging about 64 MB resident each<\/td>\n<\/tr>\n<tr>\n<td>Memory while idle<\/td>\n<td>585 MB used, 325 MB available of 911 MB<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>What the numbers say:<\/p>\n<ul>\n<li><strong>The cache saves about 43 ms per request,<\/strong> roughly 2.2 times faster. The uncached page was already quick, so the absolute gain is modest.<\/li>\n<li><strong>A MISS costs the same as no cache.<\/strong> The 79 ms MISS matches the uncached runs, so the cache adds no measurable penalty when it misses.<\/li>\n<li><strong>The cached time is mostly connection setup.<\/strong> 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.<\/li>\n<li><strong>The bigger benefit is capacity, and it was not load-tested.<\/strong> 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.<\/li>\n<li><strong>The cache stays tiny.<\/strong> One page took 72 KB, so the 100 MB cap is a safety limit, not something a small blog approaches.<\/li>\n<\/ul>\n<h2>Step 6: Clear the cache after you publish<\/h2>\n<p>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:<\/p>\n<pre><code>sudo find \/var\/cache\/nginx\/blog -type f -delete<\/code><\/pre>\n<p>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.<\/p>\n<div class=\"callout callout-tip\">\n<p><strong>Tip.<\/strong> After a PHP or theme change, also reload PHP-FPM if your OPcache is set to <code>validate_timestamps=0<\/code>. Clearing the Nginx cache alone will not make PHP re-read changed files.<\/p>\n<\/div>\n<h2>Common mistakes<\/h2>\n<ul>\n<li><strong>Caching logged-in pages.<\/strong> Without the cookie bypass, an editor&#8217;s admin bar or a commenter&#8217;s name can be served to strangers. Always keep <code>fastcgi_cache_bypass<\/code> and <code>fastcgi_no_cache<\/code> together.<\/li>\n<li><strong>Losing security headers.<\/strong> Adding <code>add_header<\/code> in a location wipes the inherited ones. Verify the count after every change.<\/li>\n<li><strong>Enabling &#8220;Cache Everything&#8221; on a CDN for the same path.<\/strong> 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.<\/li>\n<li><strong>Testing through a CDN and blaming Nginx.<\/strong> Use <code>--resolve<\/code> to hit the server directly.<\/li>\n<li><strong>Reloading without <code>nginx -t<\/code>.<\/strong> One typo can break every site on the machine.<\/li>\n<li><strong>Forgetting to purge.<\/strong> If a fixed typo does not appear, wait ten minutes or clear the cache.<\/li>\n<li><strong>Ignoring upstream cache headers.<\/strong> Forcing Nginx to cache pages that WordPress marked as private can leak personal content.<\/li>\n<\/ul>\n<h2>When page caching is not enough<\/h2>\n<p>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 <a href=\"\/wordpress\">managed WordPress hosting<\/a>, where page caching and the database stack are already tuned, or to a <a href=\"\/dedicated-server\">dedicated server<\/a> for sustained high traffic.<\/p>\n<h2>Frequently asked questions<\/h2>\n<h3>Is a FastCGI cache better than a WordPress caching plugin?<\/h3>\n<p>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.<\/p>\n<h3>Will it break comments or forms?<\/h3>\n<p>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.<\/p>\n<h3>How long should I cache pages?<\/h3>\n<p>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.<\/p>\n<h3>Does the cache work over HTTP\/2 and HTTPS?<\/h3>\n<p>Yes. The key includes the scheme, host and URI, and the protocol between the visitor and Nginx does not matter for the stored copy.<\/p>\n<h3>How do I know it is working?<\/h3>\n<p>Read the <code>X-Cache<\/code> header. HIT means Nginx answered from the cache, MISS means PHP built the page and stored it, and BYPASS means a skip rule applied.<\/p>\n<p>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 <code>nginx -t<\/code> before reloading, read <code>X-Cache<\/code> to confirm behaviour, and purge after you publish.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How to add a FastCGI page cache to a small WordPress blog on Nginx, with bypass rules, an X-Cache header, real timings and a safe rollback.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[16],"tags":[38,37,40,23,39],"class_list":["post-3295","post","type-post","status-publish","format-standard","hentry","category-servers","tag-fastcgi-cache","tag-nginx","tag-small-vps","tag-wordpress-performance","tag-x-cache"],"_links":{"self":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3295","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/comments?post=3295"}],"version-history":[{"count":1,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3295\/revisions"}],"predecessor-version":[{"id":3296,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3295\/revisions\/3296"}],"wp:attachment":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/media?parent=3295"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/categories?post=3295"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/tags?post=3295"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}