Nginx FastCGI Cache for WooCommerce Without Breaking the Cart
· 13 min read
A client emailed in a mild panic: "A customer just phoned to say her cart shows someone else's items. Another customer says the same thing. What's going on?"
The site was a mid-sized UK WooCommerce store running on a VPS I'd inherited the previous month. The previous developer had added nginx fastcgi_cache to "make it faster" after a slow-page complaint, but had copy-pasted a generic WordPress caching config without considering WooCommerce at all. The result: logged-in shoppers were being served a cached HTML page that still contained another customer's session state.
It took about forty minutes to diagnose and another hour to ship a correct config. This post is everything I wish the previous developer had known before they touched nginx.
Why WooCommerce breaks naive page caches
A plain WordPress blog is trivially cacheable: the HTML for /my-first-post/ is identical for everyone who isn't logged in. You can stick a 10-minute TTL on it and move on.
WooCommerce is not a blog. Pages include:
- A mini-cart widget in the header with live item count and total
- Stock levels that change per request
- Cross-sells and "recently viewed" based on the current user
- Nonces (CSRF tokens) embedded in add-to-cart forms
Set-Cookieheaders forwoocommerce_cart_hash,woocommerce_items_in_cart, and the session cookie
If nginx caches a full response from a logged-in customer, it caches the Set-Cookie headers as well. The next visitor to hit that cached URL gets sent those cookies and — depending on how the theme reads the session — can literally see the previous customer's cart. That's what was happening to my client.
A good WooCommerce cache config has to do four things:
- Never cache cart, checkout, my-account, or the WooCommerce REST API
- Never cache any response when the visitor has a login or cart cookie
- Strip
Set-Cookiefrom cached responses so stale session cookies can't leak - Give you a way to bypass on demand and a way to see whether a hit was cached
Here's how I wire each of those up.
Diagnosing the original config
Before touching anything, I wanted to see exactly what was being cached. I added a response header to the server block:
add_header X-FastCGI-Cache $upstream_cache_status;
Then hit the homepage with curl while logged out, and again after logging in:
curl -sI https://example.com/ | grep -i fastcgi
curl -sI -b "wordpress_logged_in_abc=user|123" https://example.com/ | grep -i fastcgi
Both returned X-FastCGI-Cache: HIT. That was the smoking gun — the cache was ignoring the login cookie entirely. Worse, piping the response through grep -i set-cookie showed that the cached HIT was handing out woocommerce_cart_hash and wp_woocommerce_session_abc123... to anyone who asked. Every visitor got the same session token until the cache entry expired.
The previous config looked roughly like this:
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") { set $skip_cache 1; }
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
Zero cookie handling. Zero WooCommerce awareness. Not fit for an ecommerce site.
The corrected config
Here is the full block I now use on every WooCommerce site I manage. I'll walk through each section below.
Cache zone definition (http context)
This goes in /etc/nginx/nginx.conf or a snippet loaded from conf.d/:
fastcgi_cache_path /var/cache/nginx/wc levels=1:2
keys_zone=WORDPRESS:100m
inactive=60m
max_size=2g
use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout updating invalid_header http_500 http_503;
fastcgi_cache_lock on;
fastcgi_cache_background_update on;
fastcgi_cache_revalidate on;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
A few things worth calling out:
fastcgi_cache_lock onprevents cache stampedes. When a hot URL expires, only one request hits PHP-FPM; everyone else waits for the new entry. This matters on a WooCommerce site where a single product page can serve hundreds of concurrent requests when it's linked from a newsletter.fastcgi_ignore_headers Set-Cookietells nginx to cache the body even though PHP is setting cookies on the response. On its own this would be dangerous — but we pair it withfastcgi_hide_header Set-Cookielater to strip the cookies from the cached copy as well, so no leakage.fastcgi_cache_background_update onmeans stale content is served immediately while nginx refreshes the entry in the background. This is the difference between afastcgi_cachethat feels instant and one that still has occasional 3-second waits.
Bypass rules (server context)
Inside the server block for the site:
set $skip_cache 0;
# 1. Never cache POST or query strings
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
# 2. Never cache admin, login, cron, XML-RPC, or feeds
if ($request_uri ~* "/wp-admin/|/wp-login.php|/wp-cron.php|/xmlrpc.php|/feed/|/sitemap(_index)?.xml") {
set $skip_cache 1;
}
# 3. Never cache WooCommerce dynamic pages
if ($request_uri ~* "/cart/|/checkout/|/my-account/|/addons/|/wc-api/|/wc-ajax=|/?add-to-cart=") {
set $skip_cache 1;
}
# 4. Never cache the WooCommerce Store API or WP REST API cart/checkout endpoints
if ($request_uri ~* "/wp-json/wc/store/") { set $skip_cache 1; }
# 5. Never cache when the visitor has a login, cart, comment, or password cookie
if ($http_cookie ~* "wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_") {
set $skip_cache 1;
}
The cookie rule is the critical one. As soon as a visitor adds something to their cart, WooCommerce sets woocommerce_items_in_cart=1 and woocommerce_cart_hash. From that point forward every request they make skips the cache entirely, so nothing personalised gets stored and nothing stale gets served back to them. It's a slightly aggressive bypass — a returning customer with an old cart cookie will always hit PHP — but that's the correct trade-off for a store.
One important note: wp_woocommerce_session_ only appears on some installs and is set by WooCommerce core as of 7.5+ with the woocommerce_session_handler default. Including it costs nothing on sites where it's not present, so I leave it in.
Location block (per site)
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# Strip Set-Cookie from anything we do cache so session tokens can't leak
fastcgi_hide_header Set-Cookie;
add_header X-FastCGI-Cache $upstream_cache_status always;
add_header Cache-Control "no-cache, must-revalidate, max-age=0" always;
}
The fastcgi_hide_header Set-Cookie line is the safety net that would have prevented the original incident. Combined with fastcgi_ignore_headers Set-Cookie up in the http context, it means nginx caches the HTML body, throws away the cookies PHP tried to attach, and never delivers a stale session token to a different visitor. Logged-in users — who need those cookies — are already bypassing the cache entirely via the cookie rule above, so they still get their cookies set as normal.
Note the always flag on add_header — without it, nginx only sends the header on 2xx responses, which makes debugging 5xx cache behaviour unnecessarily painful.
Per-site cache purging
I use the nginx-cache plugin for the WordPress side, but it needs read/write on the cache directory. Set the zone path owner to www-data (or whichever user PHP-FPM runs as) and allow the plugin to walk the tree:
sudo mkdir -p /var/cache/nginx/wc
sudo chown -R www-data:www-data /var/cache/nginx/wc
sudo chmod -R 755 /var/cache/nginx/wc
In the plugin settings, point it at /var/cache/nginx/wc and it will delete the matching files when WordPress fires save_post, edit_post, comment_post, etc. If you're on nginx Plus you can use the fastcgi_cache_purge directive instead, but the filesystem walk is fine for everything I manage.
Verifying the fix
Back to curl. I ran the same test as before — first anonymously, then with a fake login cookie, then after adding an item to the cart in a real browser:
# Logged out — should be cacheable
curl -sI https://example.com/ | grep -i fastcgi
# X-FastCGI-Cache: HIT
# With a login cookie — must bypass
curl -sI -b "wordpress_logged_in_abc=user|123" https://example.com/ | grep -i fastcgi
# X-FastCGI-Cache: BYPASS
# With a cart cookie — must bypass
curl -sI -b "woocommerce_items_in_cart=1" https://example.com/ | grep -i fastcgi
# X-FastCGI-Cache: BYPASS
# And the critical test — no Set-Cookie on cached responses
curl -sI https://example.com/shop/ | grep -i set-cookie
# (no output)
That last command is the one that matters. If grep -i set-cookie against a cached response returns anything, your config is broken and you will eventually serve one customer's session to another.
I also left Blackfire running for a day to confirm the cache hit rate was where I expected. For this client the homepage and shop archive landed at 94% HIT, product pages at 78%, and everything under /cart/ and /checkout/ at 0% — exactly the shape you want.
Common things I see go wrong
A few variations of this bug I've debugged on other people's configs:
Caching wp-json wholesale. The WordPress REST API is not cacheable as a generic rule. Some endpoints are fine, others return user-specific data. Bypassing the whole /wp-json/wc/store/ tree is non-negotiable for WooCommerce Blocks cart and checkout to function.
Missing the cart hash cookie. I've seen configs that check wordpress_logged_in_ but forget woocommerce_items_in_cart. Anonymous shoppers with items in their cart then get served cached pages showing "0 items" in the mini-cart, which looks broken and loses sales.
Caching ?add-to-cart= URLs. When a visitor clicks a simple add-to-cart link, the URL becomes /?add-to-cart=123. The if ($query_string != "") rule usually catches this, but if someone has removed that rule to cache paginated archives, you'll end up caching the add-to-cart action itself and every visitor triggers the same redirect-plus-cart-add loop.
Not stripping Set-Cookie. This is the worst of the bunch because it looks fine in testing. Everything works on your laptop. Then a week into production a customer reports seeing someone else's name in their cart, and by that point you've served thousands of requests with leaked session tokens and have no idea which customers were affected. fastcgi_hide_header Set-Cookie is mandatory, not optional.
Forgetting to reload after config changes. sudo nginx -t && sudo systemctl reload nginx. Do this every single time. I've watched people edit the site config, hit refresh, see no change, and conclude that nginx caching doesn't work.
What I add on every maintenance client
Whenever I onboard a WooCommerce site to a maintenance plan, the nginx audit is part of the first-week checklist. If they're running fastcgi_cache, I look for these six things before anything else:
fastcgi_hide_header Set-Cookieinside the PHP location block- Cookie-based bypass including
wordpress_logged_in_andwoocommerce_items_in_cart - URI-based bypass for
/cart/,/checkout/,/my-account/,/wp-json/wc/store/ X-FastCGI-Cacheresponse header so I can verify hits without guessingfastcgi_cache_lock onto prevent stampedes on hot product pages- A working purge mechanism that fires on
save_postand WooCommerce stock changes
If any of those are missing I fix them before I touch anything else. Page cache misconfigurations are the single most common cause of "intermittent weird stuff" on ecommerce sites I inherit, and they're silent until they aren't.
If you're running nginx in front of WooCommerce and you've never verified that logged-in customers bypass the cache cleanly, run the curl tests above today. It takes two minutes and it will either reassure you or save your next Friday afternoon.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing WooCommerce maintenance, emergency support, or a one-off performance fix — I can help.
