Blog/How to Set Up Nginx Caching with proxy_cache (Config, Cache Bypass, and Serving Stale Content)

NginxCachingPerformanceReverse ProxyDevOps··greencrew

How to Set Up Nginx Caching with proxy_cache (Config, Cache Bypass, and Serving Stale Content)

To set up Nginx caching, define a cache with proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=app_cache:10m max_size=1g inactive=60m; in the http block, then add proxy_cache app_cache; and proxy_cache_valid 200 10m; to your proxied location. This guide covers the full config, cache bypass for logged-in users, microcaching, serving stale content when the backend is down, and purging.

Short answer: add a proxy_cache_path line to the http block to create a cache on disk with a shared memory zone. Then, in the location that uses proxy_pass, turn it on with proxy_cache app_cache; and set how long to keep responses with proxy_cache_valid 200 301 10m;. Add add_header X-Cache-Status $upstream_cache_status; so you can see HIT and MISS with curl -I. Bypass the cache for logged-in users with proxy_cache_bypass and proxy_no_cache, and use proxy_cache_use_stale so Nginx keeps serving cached pages when the backend fails.

What Is Nginx proxy_cache?

When Nginx works as a reverse proxy, every request normally goes through to your application. With proxy_cache, Nginx saves the backend's response to disk and serves later requests for the same URL itself, without touching the app until the cached copy expires.

BenefitWhat happensWhy it matters
Faster responsesCached hits skip the app and databaseLower time to first byte
Less backend loadOnly misses reach the appSurvive traffic spikes on smaller servers
ResilienceStale content is served when the backend failsVisitors see pages instead of a 502
Simple setupBuilt into Nginx, no extra serviceNo Varnish or Redis layer to run

It works best for pages that are the same for every visitor: blogs, docs, marketing pages, product listings, and public API responses. It's a bad fit for anything personalized unless you bypass the cache for those requests.

Step 1: Define the Cache with proxy_cache_path

proxy_cache_path is only allowed in the http context. On most installs, put it in a file under /etc/nginx/conf.d/, which is included inside http:

# /etc/nginx/conf.d/cache.conf
proxy_cache_path /var/cache/nginx/app
                 levels=1:2
                 keys_zone=app_cache:10m
                 max_size=1g
                 inactive=60m
                 use_temp_path=off;

Create the directory and make sure the Nginx worker user owns it:

sudo mkdir -p /var/cache/nginx/app
sudo chown -R www-data:www-data /var/cache/nginx   # use "nginx" on RHEL-based systems

What Each proxy_cache_path Parameter Does

ParameterMeaningGuidance
/var/cache/nginx/appDirectory where cached responses are storedFast local disk; not a network mount
levels=1:2Two-level subdirectory structureKeeps any single directory from holding too many files
keys_zone=app_cache:10mName and size of the shared memory zone for keysPer the nginx docs, 1 MB holds about 8,000 keys
max_size=1gUpper limit on disk usageThe cache manager evicts least-recently-used items above this
inactive=60mRemove items not requested for this longApplies even if the item is still "valid"
use_temp_path=offWrite files directly into the cache directoryAvoids an extra copy between filesystems

inactive and validity are different things. proxy_cache_valid decides when a cached item is stale. inactive decides when an unused item is deleted. A response cached for 10 minutes that nobody requests for 60 minutes is removed from disk.

Step 2: Enable Caching in Your Location

Now use the zone in your server block:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    # ssl_certificate lines here

    location / {
        proxy_pass http://127.0.0.1:3000;
        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 app_cache;
        proxy_cache_valid 200 301 302 10m;
        proxy_cache_valid 404 1m;
        proxy_cache_key $scheme$host$request_uri;
        proxy_cache_lock on;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

What Each Caching Directive Does

DirectiveWhat it doesDefault
proxy_cacheTurns caching on with the named zoneoff
proxy_cache_validHow long to cache responses, per status codeNot set; caching relies on upstream headers
proxy_cache_keyWhat makes two requests "the same"$scheme$proxy_host$request_uri
proxy_cache_lockOnly one request fills a missing entry; others waitoff
proxy_cache_methodsWhich request methods can be cachedGET HEAD
proxy_cache_min_usesRequests needed before a response is cached1

The default key uses $proxy_host, which is the upstream address (127.0.0.1:3000), not your domain. If one backend serves several domains, use $host in the key as shown above, or pages from different sites can get mixed up.

Step 3: Check That the Cache Works

Request the same URL twice and look at the X-Cache-Status header:

curl -sI https://example.com/ | grep -i x-cache-status
# x-cache-status: MISS
curl -sI https://example.com/ | grep -i x-cache-status
# x-cache-status: HIT

Here's what each $upstream_cache_status value means:

ValueMeaning
MISSNot in cache; fetched from the backend (and stored, if cacheable)
HITServed from cache
EXPIREDCached copy was stale; fetched a fresh one
STALEServed a stale copy because of proxy_cache_use_stale
UPDATINGServed stale while another request refreshes the entry
REVALIDATEDStale copy confirmed still valid via proxy_cache_revalidate
BYPASSCache skipped because of proxy_cache_bypass

If you keep getting MISS, the backend is probably telling Nginx not to cache. See the troubleshooting table below.

Step 4: Respect (or Override) Upstream Cache Headers

Nginx listens to your application. By default it won't cache a response that has:

  • A Set-Cookie header
  • Cache-Control: private, no-cache, or no-store
  • Vary: *

When the upstream sends Cache-Control: max-age, Expires, or X-Accel-Expires, those take priority over proxy_cache_valid. That's usually what you want, because the app knows which pages are safe to cache.

If your app sends no-cache on everything and you know the pages are public, you can tell Nginx to ignore those headers for a specific location:

location /blog/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache app_cache;
    proxy_cache_valid 200 10m;
    proxy_ignore_headers Cache-Control Expires Set-Cookie;
    proxy_hide_header Set-Cookie;
}

Be careful here. Ignoring Set-Cookie and caching the response can send one visitor's session cookie to everyone. Use this only on paths that never set per-user cookies, and hide the header as shown.

Step 5: Bypass the Cache for Logged-In Users and APIs

Logged-in users, carts, dashboards, and authenticated APIs must never be served from a shared cache. Use a map (in the http context) to flag those requests:

# /etc/nginx/conf.d/cache.conf (http context)
map $http_cookie $skip_cache {
    default         0;
    ~*session       1;
    ~*wordpress_logged_in 1;
}

Then skip both reading and writing the cache for them:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache app_cache;
    proxy_cache_valid 200 10m;

    proxy_cache_bypass $skip_cache $http_authorization;
    proxy_no_cache     $skip_cache $http_authorization;

    add_header X-Cache-Status $upstream_cache_status always;
}

location /admin/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache off;
}
DirectiveEffect when its value is non-empty and not "0"
proxy_cache_bypassDon't read from the cache; go to the backend
proxy_no_cacheDon't save this response into the cache

You almost always want both. With only proxy_cache_bypass, a logged-in user's personalized response can still be saved and later served to anonymous visitors.

Step 6: Serve Stale Content When the Backend Is Down

This is where caching turns into a reliability feature. With proxy_cache_use_stale, Nginx returns the last good copy instead of an error when the backend fails:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache app_cache;
    proxy_cache_valid 200 10m;

    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;
    proxy_cache_lock on;
    proxy_cache_revalidate on;
}
DirectiveWhat it adds
proxy_cache_use_stale error timeout http_502 ...Serve stale pages when the backend errors or times out
updatingServe stale while a fresh copy is being fetched
proxy_cache_background_update onRefresh expired items in the background, so visitors get the stale copy instantly
proxy_cache_lock onOnly one request goes to the backend for a missing item
proxy_cache_revalidate onUse If-Modified-Since / If-None-Match to refresh cheaply

Stale content is only served for URLs that are already in the cache. Pages nobody visited recently will still return a 502. To fix those errors at the source, see how to fix 502 Bad Gateway in Nginx.

Step 7: Microcaching for Dynamic Sites

If your pages change often but get heavy traffic, cache them for just one second. Under load, that turns hundreds of identical requests per second into about one backend request per URL per second:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache app_cache;
    proxy_cache_valid 200 1s;
    proxy_cache_use_stale updating;
    proxy_cache_background_update on;
    proxy_cache_lock on;

    proxy_cache_bypass $skip_cache;
    proxy_no_cache     $skip_cache;
}

Visitors see content that's at most a second or two old, and a traffic spike hits Nginx instead of your database. Microcaching pairs well with Nginx load balancing and rate limiting for handling bursts.

Step 8: Clear (Purge) the Nginx Cache

Open source Nginx doesn't include a purge API. The proxy_cache_purge directive is an NGINX Plus feature. Your options:

MethodHowWhen to use
Clear everythingsudo rm -rf /var/cache/nginx/app/*After a deploy; Nginx refills the cache as requests come in
Short validityKeep proxy_cache_valid short (1–10 minutes)Content updates often, purging isn't worth the effort
Bypass headerproxy_cache_bypass $http_x_refresh; restricted to your IPsForce-refresh one URL: the fresh response replaces the cached one
Third-party modulengx_cache_purgeYou need per-URL purges and can build modules

The bypass trick works because a bypassed request still gets saved to the cache (unless proxy_no_cache also matches). Restrict it so random visitors can't hammer your backend:

geo $can_refresh {
    default 0;
    203.0.113.10 1;   # your office or CI runner
}
map $can_refresh$http_x_refresh $force_refresh {
    default 0;
    ~^1.+   1;
}
# in the location:
proxy_cache_bypass $force_refresh;
curl -sI -H "X-Refresh: 1" https://example.com/blog/my-post | grep -i x-cache-status
# x-cache-status: BYPASS

Troubleshooting Nginx proxy_cache

SymptomCauseFix
Always MISS, never HITUpstream sends Set-Cookie or Cache-Control: no-cache/privateFix headers in the app, or proxy_ignore_headers on safe paths
No X-Cache-Status header at alladd_header in a nested block canceled inheritance, or proxy_cache isn't on for that locationPut add_header in the same location as proxy_cache
One user's content shown to othersPersonalized pages cachedAdd proxy_cache_bypass and proxy_no_cache for session cookies and Authorization
Wrong site's page servedSeveral domains share one backend and the key lacks $hostproxy_cache_key $scheme$host$request_uri;
nginx -t error: "proxy_cache_path" directive is not allowed herePut inside server or locationMove it to the http context
Permission errors in error.logCache directory not owned by the worker userchown the directory to www-data or nginx
Disk filling upNo max_size, or it's too large for the diskSet max_size below free space
POST requests not cachedOnly GET and HEAD are cached by defaultIntended; don't cache POST

Monitor the Origin, Not Just the Cache

Caching creates a monitoring blind spot. With proxy_cache_use_stale, your homepage can keep returning 200 OK from cache for hours while the app behind it is completely down. Visitors on popular pages see nothing wrong, while everyone trying to log in, check out, or load an uncached page gets errors.

Monitor both layers:

MonitorURLCatches
HTTP statushttps://example.com/Nginx down, TLS or DNS failures
HTTP statushttps://example.com/healthz (with proxy_cache off)App down while the cache hides it
HTTP statusA key uncached route like /loginFailures in the parts of the site cache can't cover

Make sure the health endpoint is never cached:

location = /healthz {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache off;
}

GreenCrew checks these URLs from outside your network and alerts you on Discord, Slack, or Telegram as soon as one fails, even while the cache is still keeping the homepage up. A public status page lets you tell users which parts of the site are affected. To set up the chat side, see how to get website downtime alerts on Discord, Slack, and Telegram.

Frequently Asked Questions

Does Nginx cache responses by default?

No. Proxy caching is off until you define a zone with proxy_cache_path and turn it on with proxy_cache in a server or location block. Nginx will happily serve static files from disk without caching, but it doesn't store responses from proxied apps unless you configure it.

What's the difference between proxy_cache and browser caching?

proxy_cache stores responses on your server and shares them across all visitors. Browser caching, controlled by Cache-Control and expires, stores files in each visitor's browser. Use both: long browser caching for versioned static assets, and proxy_cache for HTML and API responses generated by your app.

Should I use Nginx caching or Varnish?

For most sites, Nginx's built-in cache does the job without adding another service. Varnish offers a more flexible configuration language and built-in purging, which helps on large sites with complex invalidation rules. If you already run Nginx and your rules are simple, start with proxy_cache.

Is fastcgi_cache the same as proxy_cache?

They work the same way and use almost identical directives (fastcgi_cache_path, fastcgi_cache_valid, fastcgi_cache_bypass). Use fastcgi_cache when Nginx talks to PHP-FPM directly with fastcgi_pass, and proxy_cache when it talks to an HTTP backend with proxy_pass.

Conclusion

Setting up Nginx caching takes one proxy_cache_path line in the http block and a few directives in your proxied location. Add X-Cache-Status so you can see hits and misses, bypass the cache for sessions and Authorization headers, and use proxy_cache_use_stale with background updates so visitors get pages even when the backend fails. Then monitor an uncached /healthz endpoint with GreenCrew, so the cache can't hide an outage from you. For the full directive reference, see the ngx_http_proxy_module documentation.