Nginx FastCGI Cache for WooCommerce Without Breaking the Cart
· 17 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: shoppers were being served a cached HTML page that still contained another customer's session state.
It was the second time in a week I'd hit this exact failure. A few days earlier, a UK pet supplies store — Ubuntu 22.04, nginx, PHP 8.2-FPM, MariaDB 10.11, Redis for object caching — had the same class of bug. A customer added a single dog collar to their cart and the cart page showed three items they'd never seen. Within an hour, two more customers reported the same thing, and one had reached checkout and seen another customer's name and shipping address pre-filled. That's not just a bug. That's a data breach. And that one stung, because I'd set up the FastCGI caching on that server myself a few months earlier, and the config had a gap I hadn't caught.
Between the two incidents it took about forty minutes to diagnose and another hour to ship a correct config. This post is everything I wish had been in place on both servers.
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 customer with an active session, it caches the Set-Cookie headers as well. The contamination sequence is short and brutal:
- Customer A visits a page with items in their session
- Nginx caches that response — including the
Set-Cookieheader carrying Customer A's WooCommerce session token - Customer B requests the same URL
- Nginx serves the cached response, cookies and all
- Customer B's browser now holds Customer A's session cookie
- Customer B sees Customer A's cart — and, at checkout, their name and address
The cached response isn't just stale HTML. It hands over the previous visitor's entire WooCommerce session to the next person who asks. That's what was happening on both sites.
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
- Never store any response that sets a cookie, so a session token can never end up in the cache
- 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 broken configs
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. On the pet supplies site, the same header check on the cart page itself returned HIT: a page that should be unique to every visitor was being served as a single cached copy to everyone. 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 inherited 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 pet-store config was one step better — it bypassed the cache for wordpress_logged_in_ cookies. But WooCommerce guests, who are the majority of shoppers on most stores, aren't logged in. They rely entirely on session cookies (wp_woocommerce_session_*) that the config completely ignored. A generic WordPress caching setup, copied without adapting it for WooCommerce, fails either way.
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;
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_headersdeliberately does NOT includeSet-Cookie. By default, nginx refuses to store any response that carries aSet-Cookieheader — and that default is the single most important safety property of this whole config. It means a response containing a fresh session token is uncacheable by definition. Overriding that default is how both of these stores ended up caching live session cookies. Some guides do it anyway to push the hit rate up and then try to compensate withfastcgi_hide_header Set-Cookie— I cover below why that patch creates a different breakage of its own.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, alongside the main wp_woocommerce_session_* identifier. 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, and it's exactly the rule the pet-store config was missing.
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;
add_header X-FastCGI-Cache $upstream_cache_status always;
add_header Cache-Control "no-cache, must-revalidate, max-age=0" always;
}
Notice what's not in this block: fastcgi_hide_header Set-Cookie. An earlier version of my own config carried it, paired with fastcgi_ignore_headers Set-Cookie, on the theory that stripping cookies from cached copies makes leakage impossible. The theory has a hole. fastcgi_hide_header is not cache-aware — it applies to every response the location proxies, and fastcgi_cache_bypass/fastcgi_no_cache only control cache lookup and storage, not header filtering. So the strip also hits bypassed responses: the login POST, the add-to-cart redirect, the first checkout page view. Those are precisely the responses that establish or refresh a WooCommerce session, and with their Set-Cookie headers removed, guests can't hold a cart and customers can't log in.
The correct safety net is nginx's own default: as long as Set-Cookie is not in fastcgi_ignore_headers, any response that sets a cookie is refused by the cache outright. Nothing carrying a session token can ever be stored, and every bypassed response keeps its cookies intact. The trade-off is honest: if some plugin sets a cookie on every anonymous page view, those pages simply won't cache and your hit rate will show it — the fix is to stop that plugin setting gratuitous cookies, not to teach nginx to ignore them.
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 tests on both sites — 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
# Cart page — must bypass
curl -sI https://example.com/cart/ | grep -i fastcgi
# X-FastCGI-Cache: BYPASS
# 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/shop/ | grep -i fastcgi
# X-FastCGI-Cache: BYPASS
# Add-to-cart action — must bypass
curl -sI "https://example.com/shop/?add-to-cart=123" | 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.
Product pages and the shop index were still being cached for anonymous visitors, keeping the performance benefit. I also left Blackfire running for a day to confirm the hit rate was where I expected. For the first 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.
Why this is worse than a performance bug
The pet supplies client got lucky: the contamination was spotted within hours because a customer noticed unfamiliar items in their cart. Consider what happens if it goes unnoticed:
- One customer's shipping address is displayed to another at checkout
- Saved payment method references could leak between sessions
- Order confirmation pages could expose another customer's order details
- Under GDPR, this is a reportable personal data breach — the ICO expects notification within 72 hours
A misconfigured page cache doesn't feel like a security control, but on an ecommerce site it is one.
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. Configs that check wordpress_logged_in_ but forget woocommerce_items_in_cart — the pet-store failure mode. Anonymous shoppers with items in their cart get served cached pages showing "0 items" in the mini-cart at best, and someone else's session at worst.
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 catches this, but if you've removed that rule to cache paginated archives, you need an explicit bypass. Beware one nginx gotcha: $arg_add-to-cart does not work, because $arg_ variables don't support hyphens — nginx parses it as $arg_add followed by the literal string -to-cart, which is always truthy and silently disables your cache on every request. Use a map on $args instead:
# In the http {} block
map $args $is_add_to_cart {
default 0;
"~(^|&)add-to-cart=" 1;
}
Then add if ($is_add_to_cart) { set $skip_cache 1; } to the bypass rules.
Adding Set-Cookie to fastcgi_ignore_headers. 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. Nginx refusing to cache cookie-bearing responses is the one default doing security work in this setup — don't override it. And don't reach for fastcgi_hide_header Set-Cookie to patch over it either: as covered above, it strips cookies from bypassed responses too and breaks logins and guest carts.
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.
If this class of bug worries you, an alternative is Redis-based full-page caching driven from PHP, where WooCommerce's cookie checks are native to the cache logic. But fastcgi_cache done properly is faster, and the config above is how I do it properly.
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 — the same audit I run as part of WordPress server management. If the site is running fastcgi_cache, I look for these six things before anything else:
Set-Cookieabsent fromfastcgi_ignore_headers(and nofastcgi_hide_header Set-Cookiebreaking bypassed responses)- Cookie-based bypass including
wordpress_logged_in_,wp_woocommerce_session_, 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 and guest shoppers 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.
