Proxy Cache #

Nginx can store backend responses to disk and serve them directly without contacting the backend again for subsequent requests. When configured correctly, this can dramatically reduce backend load — requests that would otherwise hammer the database or run heavy calculations are processed once, the result is stored, and millions of subsequent requests are served straight from cache.

But incorrectly configured caching can cause very dangerous bugs: user A gets user B’s data, or changes that should be visible immediately keep showing stale data. This article covers how it works in depth along with safe strategies.

How Proxy Cache Works #

flowchart TD
    REQ["Request arrives\nGET /api/products"] --> CHK{"In cache\n& not expired?"}
    CHK -- "HIT" --> SERVE["Nginx sends the response\ndirectly from cache\n(backend not contacted)"]
    CHK -- "MISS" --> LOCK{"proxy_cache_lock:\nIs another request\nalready going to the backend\nfor this key?"}
    LOCK -- "Yes" --> WAIT["Wait in queue\n(proxy_cache_lock_timeout)"]
    WAIT --> CHK2{"Is it in\ncache now?"}
    CHK2 -- "Yes" --> SERVE
    CHK2 -- "No" --> BACKEND
    LOCK -- "No" --> BACKEND["Nginx requests the backend\nThe backend processes & sends the response"]
    BACKEND --> STORE{"Save to cache?\nCheck bypass & no_cache conditions"}
    STORE -- "Yes" --> CACHE["Save to disk cache\n/var/cache/nginx/..."]
    STORE -- "No (bypass)" --> NOCACHE["Send directly\nto the client, not stored"]
    CACHE --> SERVE2["Send the response to the client"]

Step 1: Defining a Cache Zone #

Before you can use caching in a location, you need to define a zone at the http level. This zone defines where the cache is stored and how big it is.

http {
    # One zone for public content (API, pages, etc.)
    proxy_cache_path /var/cache/nginx/public
                     levels=1:2
                     keys_zone=public_cache:10m
                     max_size=2g
                     inactive=1h
                     use_temp_path=off;

    # Separate zone for API responses
    proxy_cache_path /var/cache/nginx/api
                     levels=1:2
                     keys_zone=api_cache:5m
                     max_size=500m
                     inactive=10m
                     use_temp_path=off;
}

Parameter explanation:

ParameterExampleWhat It Means
Path/var/cache/nginx/publicDirectory where cache files are stored
levels=1:2levels=1:22-level subdirectory structure (prevents too many files in one directory)
keys_zonepublic_cache:10mZone name and shared memory size for the index. 10MB ≈ 80,000 entries
max_size2gTotal cache size limit on disk. Nginx removes old entries (LRU) when full
inactive1hRemove entries not accessed for 1 hour, even if not yet expired
use_temp_path=off-Write directly to the cache directory, not via temp — more efficient
# Create the cache directories with the correct owner
sudo mkdir -p /var/cache/nginx/{public,api}
sudo chown nginx:nginx /var/cache/nginx/
sudo chown nginx:nginx /var/cache/nginx/public /var/cache/nginx/api

Step 2: Enabling Cache in a Location #

server {
    location /api/products/ {
        proxy_pass http://backend;

        # Use the zone defined earlier
        proxy_cache api_cache;

        # How long the cache is valid for various status codes
        proxy_cache_valid 200 30m;     # Success responses: 30 minutes
        proxy_cache_valid 301 302 5m;  # Redirects: 5 minutes
        proxy_cache_valid 404 1m;      # Not found: 1 minute
        proxy_cache_valid any 0;       # Other responses: not cached

        # Debug header — show the cache status to the client
        # HIT = from cache, MISS = from backend, BYPASS = skipped
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

$upstream_cache_status Values #

ValueMeaning
HITResponse served from cache
MISSNot in cache, request to the backend
BYPASSCache bypassed (per proxy_cache_bypass conditions)
EXPIREDIn cache but expired, request to the backend to refresh
STALEServing old cache because the backend errored (stale response)
UPDATINGEntry being refreshed by another request (cache lock)
REVALIDATEDCache still valid based on a conditional request

Cache Keys: What Distinguishes Cache Entries #

Nginx determines whether two requests are “the same request” based on the cache key. The default cache key is $scheme$proxy_host$request_uri.

You can customize this key to include other dimensions:

location /api/ {
    proxy_pass http://backend;
    proxy_cache api_cache;

    # ─── Default key (usually sufficient for public APIs) ─────────────────────
    proxy_cache_key "$scheme$request_method$host$request_uri";

    # ─── Key with language: content differs by Accept-Language ──────
    proxy_cache_key "$scheme$host$request_uri$http_accept_language";

    # ─── Key with version: for A/B testing based on a custom header ──────
    proxy_cache_key "$scheme$host$request_uri$http_x_app_version";

    # ─── Key based on specific cookies: content differs per user-group ───
    # Careful! This can make the cache very large if cookies have many variants
    proxy_cache_key "$scheme$host$request_uri$cookie_user_segment";
}

Warning: Bad Cache Keys #

# DANGEROUS: a key including the session cookie
proxy_cache_key "$scheme$host$request_uri$http_cookie";
# → Every user with a different session = a different cache entry
# → The cache is useless (more MISSes than HITs)
# → The cache can leak data between users if cookies aren't hashed correctly

# CORRECT: bypass the cache for authenticated users (see the next section)

Cache Bypass Strategies: When Not to Cache #

Not every request should be cached. This is the most important part — a wrongly strategized cache can cause data leaks between users.

Basic Principles #

Cache → OK for:
  ✓ Public content that's the same for all users
  ✓ GET requests
  ✓ Responses that don't change often
  ✓ Responses from endpoints that don't depend on user identity

Cache → DON'T for:
  ✗ Personalized responses (user profiles, shopping carts)
  ✗ POST, PUT, DELETE, PATCH (mutating requests)
  ✗ Requests with an Authorization header or session cookie
  ✗ Responses containing sensitive data
  ✗ Endpoints returning real-time data

Implementing Cache Bypass #

http {
    # ─── Detect authenticated users based on cookies ─────────────────────
    map $http_cookie $no_cache_cookie {
        default  0;
        ~*session_id=    1;  # There's a session cookie
        ~*auth_token=    1;  # There's an auth cookie
        ~*jwt=           1;  # There's a JWT cookie
    }

    # ─── Detect HTTP methods that shouldn't be cached ───────────────────────
    map $request_method $no_cache_method {
        default  0;
        POST     1;
        PUT      1;
        PATCH    1;
        DELETE   1;
    }

    server {
        location /api/ {
            proxy_pass http://backend;
            proxy_cache api_cache;
            proxy_cache_valid 200 10m;

            # proxy_cache_bypass: if the value is non-empty & non-zero → bypass
            # (the request still goes to the backend, but the response is NOT stored in cache)
            proxy_cache_bypass $no_cache_cookie $no_cache_method;

            # proxy_no_cache: if the value is non-empty & non-zero
            # → the response is not stored in cache
            proxy_no_cache $no_cache_cookie $no_cache_method;
        }

        # ─── Public endpoint — can always be cached ─────────────────────────
        location /api/public/ {
            proxy_pass http://backend;
            proxy_cache public_cache;
            proxy_cache_valid 200 1h;
            # No bypass — all users get the same response
        }

        # ─── Private endpoint — never cached ───────────────────────
        location /api/user/ {
            proxy_pass http://backend;
            # Don't use proxy_cache at all for private endpoints
        }
    }
}

Bypass Based on the Client’s Cache-Control Header #

Browsers can request a fresh copy by sending Cache-Control: no-cache. You can honor this request:

location /api/ {
    proxy_pass http://backend;
    proxy_cache api_cache;

    # Bypass if the client requests a fresh copy
    # (Ctrl+Shift+R in browsers sends Cache-Control: no-cache)
    proxy_cache_bypass $http_pragma $http_authorization $http_cache_control;
}

Stale Cache: Serving Old Cache When the Backend Errors #

One of the most useful features — Nginx can serve expired cache when the backend can’t be reached. Showing slightly old data is better than a 502 error.

location /api/ {
    proxy_pass http://backend;
    proxy_cache api_cache;
    proxy_cache_valid 200 10m;

    # Serve old (expired) cache if the backend:
    # - Errors (502, 503, 500, 504)
    # - Times out when connecting
    # - Is being updated by another request
    proxy_cache_use_stale error timeout updating
                          http_500 http_502 http_503 http_504;

    # How long stale cache may be served
    # (beyond the normal cache_valid time)
    proxy_cache_revalidate on;

    # Thundering herd protection:
    # Only one request is sent to the backend to refresh an expired cache entry
    # Other requests wait for that one request's result
    proxy_cache_lock on;
    proxy_cache_lock_timeout 5s;
    proxy_cache_lock_age 10s;
}

Background Update: Refreshing Cache Silently #

For content that changes periodically, you can tell Nginx to refresh the cache in the background when content is about to expire:

location /api/products/ {
    proxy_pass http://backend;
    proxy_cache api_cache;
    proxy_cache_valid 200 5m;

    # Serve from cache WHILE refreshing in the background
    # Clients don't wait for the refresh — they get the response immediately
    proxy_cache_background_update on;

    # Don't delete the expired cache before a new version exists
    proxy_cache_use_stale updating;
}

Honoring Cache Headers from the Backend #

Nginx can also use standard HTTP headers from the backend to determine how long the cache is valid:

# In the Node.js backend:
# res.set('Cache-Control', 'public, max-age=3600, s-maxage=7200');
# s-maxage: specifically for shared caches (including the Nginx proxy cache)

location /api/ {
    proxy_pass http://backend;
    proxy_cache api_cache;

    # Use the Cache-Control value from the backend (default)
    # If the backend doesn't set Cache-Control, use proxy_cache_valid

    # Or: ignore the Cache-Control from the backend, use Nginx configuration
    proxy_ignore_headers Cache-Control Expires Set-Cookie;
    proxy_cache_valid 200 30m;  # Nginx controls, backend ignored
}

Note: if the backend sends Set-Cookie, Nginx by default doesn’t store the response in cache (because a cookie usually means a personalized response). To ignore this:

location /api/public/ {
    proxy_pass http://backend;
    proxy_cache api_cache;

    # Ignore Set-Cookie from the backend (if you're sure this endpoint isn't personalized)
    proxy_ignore_headers Set-Cookie;
    proxy_cache_valid 200 1h;

    # Don't forward Set-Cookie to the client (since we already ignored it)
    proxy_hide_header Set-Cookie;
}

Purging Cache #

Manually via Shell #

# See the cache contents
ls -la /var/cache/nginx/api/

# Delete all cache (careful — all subsequent requests will MISS)
sudo rm -rf /var/cache/nginx/api/*

# After deleting, no Nginx reload is needed — it rebuilds the cache automatically

Purge via API (Using the ngx_cache_purge Module) #

# Install the module (Ubuntu/Debian)
sudo apt install libnginx-mod-http-cache-purge
location ~ /purge(/.*) {
    # Restrict purge access to internal servers only
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;

    # Purge the cache with a matching key pattern
    proxy_cache_purge public_cache "$scheme$host$1";
}
# Purge a specific endpoint
curl -X PURGE http://example.com/purge/api/products

# The purge removes the cache entry for /api/products
# The next request to /api/products will MISS (fresh from the backend)

Invalidation Strategy via the Backend #

A safer alternative: the backend performs a purge via an HTTP request to Nginx after a data update:

# Python: after updating a product in the database
import requests

def invalidate_product_cache(product_id):
    try:
        requests.request(
            "PURGE",
            f"http://nginx-internal/purge/api/products/{product_id}",
            timeout=5
        )
    except Exception as e:
        logger.warning(f"Cache purge failed: {e}")
        # Not critical — the cache expires on its own

Monitoring Cache Performance #

# Add a log format that includes the cache status
log_format cache_log '$remote_addr [$time_local] "$request" '
                     '$status $upstream_cache_status '
                     '$upstream_response_time $request_time';

# Calculate the hit rate from the log
# (run after a few hours of traffic)
awk '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

# Example output:
# 8543 HIT      ← 85% hit rate (great!)
# 1203 MISS
#  254 BYPASS

# Cache hit rate = HIT / (HIT + MISS) × 100%
# Target: > 80% for frequently accessed public endpoints

# Check the cache size in use
du -sh /var/cache/nginx/

# Details per zone (if there's a specific log):
nginx -T | grep proxy_cache_path

Troubleshooting a Cache That Isn’t Working #

When the cache doesn’t work as expected, follow this checklist:

# 1. Check whether the cache directory exists and has correct permissions
ls -la /var/cache/nginx/
# Owner must be nginx:nginx or www-data:www-data

# 2. Make sure the zone is defined at the http level, not server/location
grep -n "proxy_cache_path" /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf

# 3. Check whether $upstream_cache_status is in the response
curl -sI https://example.com/api/products | grep -i "x-cache"
# If absent: make sure add_header X-Cache-Status $upstream_cache_status;

# 4. If always BYPASS: check the bypass conditions
# Add temporary debug headers:
# add_header X-Cache-Bypass-Cookie $no_cache_cookie always;
# add_header X-Cache-Bypass-Method $no_cache_method always;

# 5. Check whether the backend sends headers preventing caching
curl -sI http://localhost:3000/api/products | grep -iE "cache-control|pragma|set-cookie"
# "Cache-Control: no-store" or "Set-Cookie" from the backend prevents caching
# Use proxy_ignore_headers to ignore them

Table of Causes for a Non-Working Cache #

SymptomLikely CauseSolution
Always MISSproxy_cache_bypass is satisfiedCheck the map variable conditions
Always MISSBackend sends Cache-Control: no-storeAdd proxy_ignore_headers Cache-Control
Always BYPASSproxy_no_cache is satisfiedDebug the bypass variable values
No X-Cache headeradd_header not includedAdd include snippets/security-headers.conf containing that header
Cache gone after restartinactive too shortIncrease the inactive value in proxy_cache_path
Cache not formingDirectory doesn’t existmkdir -p and set the correct ownership

Summary #

  • Define proxy_cache_path at the http level before you can use proxy_cache in a location. Use separate zones for different content types.
  • $upstream_cache_status as a debug header to see HIT/MISS/BYPASS — mandatory during the configuration phase.
  • Safe bypass strategy: use the map directive to detect authenticated users (via cookies/headers), then set proxy_cache_bypass and proxy_no_cache together.
  • proxy_cache_use_stale error timeout serves old cache when the backend errors — making the application more resilient.
  • proxy_cache_lock on prevents the thundering herd — only one request to the backend when many clients request the same expired cache entry at once.
  • Don’t cache POST/PUT/DELETE, responses with personal cookies, or endpoints containing sensitive data.
  • For purging, use the ngx_cache_purge module or rm -rf and let Nginx rebuild the cache automatically.

← Previous: Buffering & Timeouts   Next: Round Robin →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact