Server

Nginx Content Caching

By Prabath Thalangama· October 24, 2026· 3 min read
#nginx#caching#performance

Introduction

proxy_cache lets Nginx store responses from your backend and serve repeat requests directly — no round trip to the app. Done right it removes most load; done wrong it serves stale or user-specific content to the wrong people.

Basic setup

# http {} context
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:100m
                 max_size=10g inactive=60m use_temp_path=off;

# server {} / location {}
location / {
    proxy_pass http://backend;
    proxy_cache app_cache;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 302 10m;
    proxy_cache_valid 404 1m;
    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;   # HIT / MISS / EXPIRED / STALE / BYPASS
}
  • keys_zone — shared memory for keys/metadata (100m ≈ ~800k entries); the bodies go on disk under proxy_cache_path.
  • levels=1:2 — directory nesting so one dir doesn't hold millions of files.
  • X-Cache-Status — add this header while tuning so you can see HIT/MISS in the response.

The cache key

Default is $scheme$proxy_host$request_uri. Add $host if you serve multiple hostnames. Do not include cookies or auth headers unless you mean to cache per-user (you usually don't). Do add anything that changes the response:

proxy_cache_key "$scheme$host$request_uri$http_accept_encoding";
map $http_accept $is_json { ~application/json 1; default 0; }
# ...append $is_json if content negotiates

Deciding what to cache

# Never cache authenticated requests
proxy_cache_bypass $http_authorization $cookie_sessionid;
proxy_no_cache     $http_authorization $cookie_sessionid;

# Cache static assets hard, HTML briefly, API not at all
location ~* \.(jpg|css|js|woff2)$ { proxy_cache_valid 200 7d; }
location = /                       { proxy_cache_valid 200 5m; }
location /api/                     { proxy_pass http://backend; }  # no proxy_cache
  • proxy_cache_bypass — if true, fetch from upstream (but still store the result). proxy_no_cache — if true, don't store. Set both for "always fresh for logged-in users".
  • Honour upstream Cache-Control: no-store / private by default; override with proxy_ignore_headers Cache-Control Set-Cookie; carefully.

Stale content = resilience

proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;    # serve stale, refresh in the background
proxy_cache_lock on;                 # one request fills the cache, others wait (no stampede)

Now if the backend is down or slow, users get the last good copy instead of an error.

Micro-caching dynamic pages

Even a 1-second cache on a homepage collapses a traffic spike:

location / {
    proxy_pass http://backend;
    proxy_cache app_cache;
    proxy_cache_valid 200 1s;
    proxy_cache_lock on;
    proxy_cache_use_stale updating;
}

1000 req/s to the homepage becomes ~1 req/s to the app. Only safe for content that's identical for all anonymous users and can tolerate 1s staleness.

Purging

Open-source Nginx has no PURGE method built in. Options:

  • proxy_cache_bypass with a secret header to force a refresh of one URL.
  • Delete the file: the path is md5("$cache_key") under the levels dirs — scriptable but fiddly.
  • ngx_cache_purge third-party module, or Nginx Plus, adds real purge.
  • Just use short TTLs and let it expire.

Verification and troubleshooting

curl -sI https://example.com/ | grep -i x-cache-status
du -sh /var/cache/nginx
nginx -T | grep -A5 proxy_cache_path
  • Always MISS — the response has Set-Cookie (Nginx won't cache by default), Cache-Control: no-cache/no-store/private, Expires in the past, or a Vary: * / Vary: Cookie. Check upstream headers; proxy_ignore_headers if appropriate.
  • HIT for logged-in users seeing each other's data — no proxy_no_cache/proxy_cache_bypass on the session cookie, or the cache key doesn't vary on auth. Serious bug — audit immediately.
  • Cache directory fills the disk — max_size not set or too high; inactive too long. Nginx's cache manager prunes to max_size but lags under heavy write.
  • Stale content after a deploy — TTL hasn't expired; add a purge step to the deploy, or version asset URLs (hashed filenames) so new content = new key.
  • Thundering herd on expiry (many MISS at once) — proxy_cache_lock on + proxy_cache_use_stale updating.
  • X-Cache-Status: BYPASS always — a proxy_cache_bypass variable is non-empty/non-zero for every request (e.g. a cookie everyone has). Narrow the condition.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.