proxy_cache_pathdeclares the cache on disk and must sit in the http block, outside any server block.- The cache key decides what counts as the same page. Get it wrong and visitors see each other's content.
- Never cache a response for a logged-in user or a session cookie holder. Set proxy_cache_bypass before anything else.
proxy_cache_use_stale updatingkeeps the site fast during a cache refresh and shields the backend from a stampede.- Add
X-Cache-Statusto your responses. Without it you are guessing whether the cache is working at all.
What does an Nginx reverse proxy cache do?
It stores the responses your application produced and serves the next identical request straight from disk, without touching PHP or the database. A cached page is answered in single-digit milliseconds by a process doing almost no work, which is why one caching layer usually buys more than any amount of application tuning.
| No cache | Reverse proxy cache hit | |
|---|---|---|
| Work per request | Nginx, PHP-FPM, database, template render | Nginx reads a file |
| Typical response time | 200ms to 2s | 1ms to 10ms |
| Concurrent requests a small VPS handles | Tens | Thousands |
| What a traffic spike costs you | CPU, memory, possibly an outage | Disk reads |
The difference is not incremental. A cache hit skips almost the entire stack.
This is page caching, not object caching
A reverse proxy cache stores whole finished pages for anonymous visitors. Object caching stores the results of individual database queries and helps logged-in users and dynamic pages too. They solve different problems and most busy sites want both. See WordPress object caching with Redis.
How do I configure proxy_cache_path?
proxy_cache_path defines where the cache lives, how it is indexed and how large it may grow. It belongs in the http block, in nginx.conf or a file included from it. Putting it inside a server block is the most common first error and Nginx will refuse to start.
proxy_cache_path /var/cache/nginx/proxy
levels=1:2
keys_zone=site_cache:100m
max_size=10g
inactive=60m
use_temp_path=off;
# Log the cache result on every request. Without this you are guessing.
log_format cached '$remote_addr - $upstream_cache_status [$time_local] '
'"$request" $status $body_bytes_sent';
access_log /var/log/nginx/access.log cached;| Parameter | Meaning | Sensible value |
|---|---|---|
| levels=1:2 | Directory nesting, so no single folder holds a million files | 1:2, always |
| keys_zone=name:100m | Shared memory for keys and metadata only | 100m holds roughly 800,000 entries |
| max_size | Ceiling for cached content on disk | 10g, or well under your free space |
| inactive | Evict anything not requested in this window | 60m for a busy site, 24h for a quiet one |
| use_temp_path=off | Write directly into the cache directory | off, always: avoids a pointless copy |
keys_zone sizes the metadata in RAM, not the content. max_size is the disk figure.
max_size must fit the disk you have
Nginx will fill up to max_size regardless of what else needs that space. Set it to 10g on a partition with 12g free and you will eventually take the database down, because a full disk stops MySQL writing. Check with df -h /var/cache and leave real headroom.
How do I set up the server block?
The server block decides what gets cached, for how long, and crucially what does not. The bypass rules must come first: a caching configuration that is fast but serves one visitor's account page to another is worse than no cache at all.
server {
listen 443 ssl http2;
server_name example.com;
# ---- Decide what must never be cached, FIRST ----
set $skip_cache 0;
# POST requests and anything with a query string
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
# Admin, login, REST, sitemaps, cron
if ($request_uri ~* "/wp-admin/|/wp-json/|/xmlrpc.php|wp-.*.php|/feed/|sitemap(_index)?.xml") {
set $skip_cache 1;
}
# Anyone with a login, comment or WooCommerce session cookie
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in|woocommerce_items_in_cart|woocommerce_cart_hash") {
set $skip_cache 1;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache site_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 301 302 60m;
proxy_cache_valid 404 1m;
proxy_cache_bypass $skip_cache; # do not SERVE from cache
proxy_no_cache $skip_cache; # do not STORE in cache
# Serve stale content rather than queueing behind a slow backend
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
}
}Both bypass directives, or you leak private pages
With only proxy_cache_bypass set, a logged-in user's page is fetched fresh but still written into the cache, where the next anonymous visitor will be served it. proxy_no_cache is what prevents the store. Set both, every time.
How do I choose the right cache key?
The cache key is what Nginx compares to decide whether two requests want the same thing. Too narrow and different visitors share a response they should not. Too broad and your hit rate collapses because every visitor gets their own copy.
# Standard. Right for most sites.
proxy_cache_key "$scheme$request_method$host$request_uri";
# Separate mobile and desktop, if the backend serves different HTML
map $http_user_agent $device {
default desktop;
"~*android|iphone|ipad|mobile|blackberry" mobile;
}
proxy_cache_key "$scheme$request_method$host$request_uri$device";
# Separate by currency or language cookie on a multi-region shop
proxy_cache_key "$scheme$request_method$host$request_uri$cookie_currency";Query strings are the quiet hit-rate killer. Every unique ?utm_source= value creates a separate cache entry for the same page. Either exclude query strings from the key entirely for pages that ignore them, or strip tracking parameters at the edge before they reach the cache.
How do I test that the cache is actually working?
The X-Cache-Status header added above is the whole answer. Request the same page twice: the first should be MISS, the second HIT. If the second is still MISS, something is preventing storage and the header tells you which state you are in.
# Validate the config, then reload with no dropped requests
sudo nginx -t && sudo systemctl reload nginx
# First request: expect MISS
curl -sI https://example.com/ | grep -i x-cache-status
# Second request: expect HIT
curl -sI https://example.com/ | grep -i x-cache-status
# Confirm the speed difference
curl -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' https://example.com/
# Live hit rate from the access log
awk '{print $4}' /var/log/nginx/access.log | sort | uniq -c | sort -rn| X-Cache-Status | Means | What to do |
|---|---|---|
| HIT | Served from cache | Working correctly |
| MISS | Not in cache, fetched from the backend | Normal on a first request; a problem if it never changes |
| BYPASS | A bypass rule matched | Expected for admin and logged-in requests |
| EXPIRED | Was cached, had aged out, refetched | Normal. Raise proxy_cache_valid if it happens constantly |
| STALE | Served old content because the backend failed | The backend is down or slow. Investigate that |
| UPDATING | Serving old content while refreshing | Working exactly as intended |
Permanent MISS on a page with no query string almost always means a Set-Cookie header on the response, which suppresses caching.
A Set-Cookie header stops caching silently
By default Nginx will not cache any response carrying Set-Cookie. WordPress themes and plugins set cookies far more often than people expect. Check with curl -sI https://example.com/ | grep -i set-cookie. If a harmless cookie is the cause, drop it with proxy_ignore_headers Set-Cookie plus proxy_hide_header Set-Cookie, and be certain it carries nothing per-visitor first.
How do I purge the cache?
Selective purging (proxy_cache_purge) is an Nginx Plus feature. On open source Nginx you either delete files from the cache directory or compile in the third-party ngx_cache_purge module. Deleting files is crude but reliable and needs nothing extra.
# Everything. Blunt, but always works.
sudo find /var/cache/nginx/proxy -type f -delete
sudo systemctl reload nginx
# One URL: the file is named after the MD5 of the cache key
KEY="httpsGETexample.com/about/"
HASH=$(printf '%s' "$KEY" | md5sum | cut -d' ' -f1)
sudo find /var/cache/nginx/proxy -name "$HASH" -delete
# Everything matching a path
sudo grep -rl 'example.com/blog/' /var/cache/nginx/proxy | xargs -r sudo rmA full purge is a thundering herd
Deleting the whole cache on a busy site sends every visitor to the backend at once, which is how a cache clear turns into an outage. proxy_cache_lock on limits it by letting only one request per key through to the backend while the others wait. Purge selectively where you can.
Caching in front of a slow application hides the problem rather than fixing it. Every cache miss, every logged-in visitor and every checkout still pays the full cost, so tune what is behind the cache too: PHP-FPM tuning and object caching are the two that matter most. On G7Cloud this layer is configured and maintained for you, with full-page caching already in front of every site.
Frequently asked questions
Where does proxy_cache_path go in the Nginx config?
In the http block, typically in /etc/nginx/nginx.conf. It cannot go inside a server or location block: Nginx will fail its config test with "directive is not allowed here". You then reference the zone by name with proxy_cache inside the location block.
Why is my Nginx cache always showing MISS?
The most common cause is a Set-Cookie header on the response, which suppresses caching by default. Other causes are a Cache-Control: no-cache header from the backend, a bypass rule matching more than you intended, or a query string creating a new cache entry every time.
How do I stop Nginx caching pages for logged-in users?
Set a $skip_cache variable when a login or session cookie is present, then apply it to both proxy_cache_bypass and proxy_no_cache. Only setting bypass means the private page is still written to the cache and can be served to the next anonymous visitor.
What is the difference between proxy_cache_bypass and proxy_no_cache?
proxy_cache_bypass stops Nginx serving a stored response, so the request goes to the backend. proxy_no_cache stops Nginx storing the response it gets back. You need both for private content: one prevents reads, the other prevents writes.
How much disk space should I give the Nginx cache?
Enough for your commonly requested pages with room to spare on the partition. A typical content site needs 1 to 5 GB. The number that matters is free space: Nginx will fill up to max_size regardless of what else needs the disk, and a full disk takes the database down.
Can I use an Nginx reverse proxy cache with WooCommerce?
Yes, but the bypass rules have to include the WooCommerce session cookies (woocommerce_items_in_cart and woocommerce_cart_hash) as well as cart, checkout and account pages. Cache a checkout page once and customers will see each other's basket.
About G7Cloud Engineering
Articles written by the engineers who build and run G7Cloud: UK managed hosting and the AI Website Builder. We write about what we operate every day: containers, backups, databases, and the small-business websites that run on them.
More about G7Cloud →