NGINX proxy_cache not caching: why responses bypass the cache

After enabling proxy_cache and defining the cache path, upstreams still take every hit. Access logs show $upstream_cache_status as BYPASS or MISS, hit rate stays near zero, and nothing appears in the error log. NGINX applies a strict request-phase and response-phase decision tree before anything enters the cache. If any condition fails, the response is never stored. The symptom looks like upstream overload, but the root cause is usually a directive, a header, or a missing validity window.

What this means

NGINX caching has two phases. The request phase decides whether to look up or bypass the cache (proxy_cache_bypass, request method, proxy_no_cache). The response phase decides whether the upstream response can be stored (Cache-Control, Set-Cookie, Expires, proxy_cache_valid, proxy_buffering). BYPASS means a request-phase rule matched. MISS on a cacheable resource usually means a response-phase rule blocked storage. MISS on every request to a static asset means the response reached the cache zone but failed the storage check.

flowchart TD
    A[Client request] --> B{Method GET/HEAD?}
    B -->|No| C[BYPASS or proxy]
    B -->|Yes| D{proxy_cache_bypass true?}
    D -->|Yes| E[BYPASS]
    D -->|No| F{Cache key exists?}
    F -->|Yes| G[Serve cached entry]
    F -->|No| H[Fetch from upstream]
    H --> I{proxy_buffering on?}
    I -->|No| J[Stream to client; no store]
    I -->|Yes| K{Set-Cookie present?}
    K -->|Yes| L[Skip storage]
    K -->|No| M{Cache-Control prohibits?}
    M -->|Yes| N[Skip storage]
    M -->|No| O{Expires or max-age or proxy_cache_valid?}
    O -->|No| P[Skip storage]
    O -->|Yes| Q[Store in cache]

Common causes

CauseWhat it looks likeFirst thing to check
Set-Cookie in upstream responseMISS or BYPASS; every response carries a session cookieResponse headers for Set-Cookie
Cache-Control: no-cache, private, or no-storeMISS; upstream explicitly forbids cachingcurl -I to upstream or check application headers
Missing proxy_cache_validMISS; upstream sends no Expires or Cache-Control: max-agenginx -T | grep proxy_cache_valid
Request method is not GET or HEADBYPASS; POST, PUT, PATCH, or DELETE requestsAccess log method and proxy_cache_methods
proxy_cache_bypass or proxy_no_cache evaluates trueBYPASS; query args, cookies, or headers trigger the conditionnginx -T | grep -E 'proxy_cache_bypass|proxy_no_cache'
proxy_buffering is offMISS; responses stream straight to clientnginx -T | grep proxy_buffering
Vary: * or overly broad Vary headerMISS; cache refuses to store or fragments keysResponse headers for Vary

Quick checks

# Cache status distribution (requires $upstream_cache_status in log_format)
grep -oP 'cache_status=\K[A-Z]+' /var/log/nginx/access.log | tail -10000 | sort | uniq -c | sort -rn
# Show all cache-related directives in effective configuration
nginx -T 2>/dev/null | grep -E 'proxy_cache|proxy_cache_valid|proxy_cache_bypass|proxy_no_cache|proxy_ignore_headers|proxy_buffering|proxy_cache_methods|proxy_cache_key'
# Inspect response headers from upstream directly
curl -s -I -o /dev/null -D - http://127.0.0.1:8080/ | grep -iE 'cache-control|expires|set-cookie|vary'
# Sample recent BYPASS and MISS requests to find patterns
grep -E 'BYPASS|MISS' /var/log/nginx/access.log | tail -20
# Verify cache path disk usage and health
du -sh /var/cache/nginx/ 2>/dev/null
df -h /var/cache/nginx/ 2>/dev/null
# Check for shared memory zone or cache allocation errors
grep -iE 'could not allocate|proxy_cache' /var/log/nginx/error.log | tail -20

How to diagnose it

  1. Confirm the cache status pattern. Parse access logs for $upstream_cache_status. If the dominant status is BYPASS, focus on request-phase conditions. If it is MISS, focus on response-phase storage rules.
  2. Check request-phase exclusions. Review proxy_cache_bypass and proxy_no_cache conditions. Look for variables like $cookie_nocache, $arg_nocache, $http_pragma, or $http_authorization. These trigger BYPASS when they evaluate to non-empty and non-zero.
  3. Verify the request method. By default, only GET and HEAD are cached. If your workload includes cacheable POST requests, proxy_cache_methods must explicitly include them.
  4. Inspect upstream response headers. Hit the upstream directly with curl -I. If you see Set-Cookie, Cache-Control: no-cache, Cache-Control: private, Cache-Control: no-store, or Vary: *, NGINX will refuse to cache unless you override with proxy_ignore_headers.
  5. Validate proxy_cache_valid. If the upstream does not send Expires or Cache-Control: max-age, NGINX will not cache the response unless proxy_cache_valid is configured for the status code.
  6. Check proxy_buffering. Caching requires buffering. If proxy_buffering off is set, responses stream directly to the client and are never stored.
  7. Audit the cache key. If proxy_cache_key includes volatile arguments like session tokens or timestamps, every request generates a unique key. This appears as MISS even though storage technically succeeds. The default key is based on scheme, proxy host, and request URI.
  8. Check cache zone and disk health. A full cache disk or exhausted keys_zone can prevent new entries. Look for could not allocate node in the error log for zone exhaustion, and verify disk usage for disk-level write failures.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
Cache hit rate (HIT / total cacheable)Measures cache effectivenessDrop >10% from baseline sustained
BYPASS rateReveals request-phase exclusionsSustained BYPASS for resources that should be cached
MISS rate on warmed assetsReveals response-phase storage failuresHigh MISS for URLs that previously returned HIT
Upstream request rateCorrelates upstream load with cache missesSpike correlating with MISS/BYPASS increase
Cache disk usageA full disk stops new storageUsage >90% of max_size or partition capacity
Shared memory zone errorsZone exhaustion blocks new entriescould not allocate node in error log

Fixes

If the upstream unconditionally sets cookies, NGINX treats the response as uncacheable. To cache the response anyway, add proxy_ignore_headers Set-Cookie;. To prevent that cookie from reaching clients, also add proxy_hide_header Set-Cookie;. Hiding the header alone does not make the response cacheable.

Tradeoff: caching responses that contain Set-Cookie can leak session data across users. Only ignore this header for truly public content.

Upstream Cache-Control is too restrictive

When upstream sends Cache-Control: no-cache, private, or no-store, NGINX respects these by default. If the upstream is overly conservative and you control the cache TTL at the edge, add proxy_ignore_headers Cache-Control; and pair it with explicit proxy_cache_valid directives.

Tradeoff: you override the upstream application’s intent. Ensure TTLs match content freshness requirements.

Missing proxy_cache_valid

Without proxy_cache_valid, NGINX requires either an Expires header with a future date or Cache-Control: max-age to store anything. If the upstream sends neither, add a validity window:

proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;

Tradeoff: proxy_cache_valid any 5m caches every status code, including errors. Be specific.

Non-GET/HEAD methods

If you must cache idempotent POST responses, explicitly enable them:

proxy_cache_methods GET HEAD POST;

Tradeoff: POST caching is risky for non-idempotent endpoints. Use only for safe, stable payloads.

proxy_cache_bypass or proxy_no_cache conditions

Audit conditions like:

proxy_cache_bypass $cookie_nocache $arg_nocache $http_pragma;

Remove variables that are incorrectly matching your traffic. If you need authenticated caching, consider varying the cache key by a stable user identifier instead of blanket bypassing.

Tradeoff: removing bypass conditions increases the risk of serving cached private content to the wrong client.

proxy_buffering is off

Re-enable buffering:

proxy_buffering on;

NGINX cannot cache responses that are not buffered. This directive must be enabled for the cache store to receive the full response body.

Aggressive Vary headers

If the upstream sends Vary: Cookie, NGINX creates a separate cache entry per unique cookie value, which effectively fragments the cache into useless partitions. Use proxy_ignore_headers Vary; only if you can guarantee content does not actually vary by the listed headers. A better fix is to correct the upstream application to emit accurate Vary values.

Prevention

  • Log $upstream_cache_status in your access log format from day one. Without it, cache behavior is invisible.
  • Set explicit proxy_cache_valid windows for every cached location. Do not rely solely on upstream Expires headers.
  • Review upstream response headers before enabling caching in production. Know which headers trigger exclusion.
  • Keep proxy_cache_key stable. Exclude volatile query parameters that do not affect the response.
  • Monitor cache hit rate and BYPASS rate as primary indicators, not just upstream latency.
  • Size proxy_cache_path disk and keys_zone generously. Track disk usage and error logs for allocation failures.

How Netdata helps

  • Charts $upstream_cache_status per dimension (HIT, MISS, BYPASS, EXPIRED, STALE) via the web_log collector, so you can see when storage stops working.
  • Correlates cache hit rate drops with upstream response time and connection state changes in the same timeline.
  • Surfaces error log patterns like could not allocate node that indicate shared memory zone exhaustion.
  • Monitors cache-partition disk I/O latency to rule out disk saturation.
  • Tracks nginx worker file descriptors and connection slot utilization to rule out capacity cliffs that masquerade as cache misconfiguration.