How nginx proxy_cache works for API responses
Nginx proxy caching stores upstream responses on local disk and serves later requests for the same resource without contacting the backend. For read-heavy API endpoints such as catalogs, configuration and public reference data, this cuts backend load and removes upstream latency from the hot path. It suits teams that already run nginx as a reverse proxy and want shared caching in front of services they control.
The request path is short. Nginx computes a cache key for the incoming request and looks it up in shared memory. On a HIT, it reads the stored response from disk and returns it. On a MISS, it forwards the request upstream, returns the response, and writes it to the cache if the response is cacheable.
Storage is declared once with proxy_cache_path:
keys_zonenames a shared memory zone and sets its size. The zone holds keys and metadata, not response bodies.levelssets the subdirectory hierarchy so that no single directory holds too many files.max_sizecaps disk usage. When the cache exceeds it, the cache manager removes the least recently used entries.inactiveremoves entries that have not been accessed within the period, even if they are still fresh.use_temp_path=offwrites files directly into the cache directory, which avoids copying them from a separate temp path.
You then enable caching in a location with proxy_cache, which references the zone by name.
Each nginx instance keeps its own cache. Behind a load balancer, hit rates are per node, and a purge on one node does not affect the others. An undersized keys_zone or max_size causes early eviction, and the resulting low hit rate is easy to mistake for a poor key design. Disk-backed caching also adds I/O, so plan disk capacity and throughput as you would for any other storage workload.
# http context: declare cache storage once
# levels=1:2 -> two-level subdirectories to keep directories small
# keys_zone -> shared memory for keys and metadata (1 MB holds roughly 8,000 keys)
# max_size -> disk cap; cache manager evicts least recently used entries beyond it
# inactive -> evict entries not accessed for 60m, even if still fresh
# use_temp_path -> write directly into the cache dir instead of copying from a temp path
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m
max_size=5g inactive=60m use_temp_path=off;
upstream api_backend {
server 10.0.0.10:8080;
server 10.0.0.11:8080;
}
server {
listen 80;
server_name api.example.com;
# Read-heavy endpoint: enable caching by referencing the zone name
location /v1/catalog/ {
proxy_cache api_cache;
proxy_cache_key $scheme$proxy_host$request_uri;
proxy_cache_valid 200 5m; # cache successful responses for 5 minutes
proxy_cache_methods GET HEAD; # only cache safe reads
# Expose HIT / MISS / EXPIRED etc. to clients for debugging
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass
}
# Writes and user-specific endpoints bypass the cache entirely
location /v1/ {
proxy_pass
}
}
A minimal setup that declares a disk-backed cache with proxy_cache_path, enables it for a read-heavy API location with proxy_cache, and exposes HIT/MISS status through a response header.
Designing the cache key: what goes in and what gets normalized
The default key, $scheme$proxy_host$request_uri, assumes the URL alone determines the response. For most APIs that is false. A tenant ID resolved from a header or token, an API-Version header, or Accept choosing between JSON and CSV all change the body without changing the URI. Any input that changes the response but is missing from the key is a data leak: the first tenant's response is served to the next one.
The opposite mistake is quieter. Every variable you add multiplies the number of distinct entries. Put a raw Authorization header or session cookie in the key, and each user gets a private cache with a hit ratio near zero.
| Key design | Failure mode | Symptom |
|---|---|---|
| Too narrow (missing tenant, version, or format) | Cross-tenant or cross-user data exposure | Wrong data served, often noticed late |
Too wide (raw tokens, full User-Agent, unnormalized query) |
Fragmentation | Hit ratio near zero, origin load unchanged |
Normalize before keying. Nginx does not reorder query parameters, so ?a=1&b=2 and ?b=2&a=1 are separate entries. Tracking parameters such as utm_* fragment the cache the same way. Build the key from the specific $arg_* values that matter, and use map to collapse headers like Accept into a small set of canonical values.
Upstream Vary adds a second, hidden key. Nginx stores one variant per key. A request whose varied headers differ is a miss, and the new response replaces the cached one, so variants keep evicting each other. Vary: Authorization or Vary: User-Agent therefore destroys the hit ratio without showing up in your key definition. Vary: * disables caching entirely. Put the dimensions you need in the key explicitly. If the upstream emits Vary you have already accounted for, use proxy_ignore_headers Vary.
# http context: collapse request inputs into a few canonical values
map $http_accept $api_format {
default json; # absent, */*, or application/json
~*text/csv csv;
}
# Tenant ID is set by a trusted auth layer, never taken from the client.
# If it is missing, skip the cache instead of sharing one entry.
map $http_x_tenant_id $no_tenant {
"" 1;
default 0;
}
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m
max_size=2g inactive=10m use_temp_path=off;
server {
listen 80;
location /api/products {
proxy_cache api_cache;
# Build the key from each input that changes the response.
# Name only the query args that matter, in a fixed order, so
# ?page=2&limit=50 and ?limit=50&page=2&utm_source=x share one entry.
proxy_cache_key "$scheme|$proxy_host|$uri|t=$http_x_tenant_id|v=$http_api_version|f=$api_format|page=$arg_page|limit=$arg_limit";
proxy_cache_bypass $no_tenant;
proxy_no_cache $no_tenant;
# Upstream sends Vary: Accept, API-Version. Both are already in the
# key, so ignore Vary to stop variants from evicting each other.
proxy_ignore_headers Vary;
proxy_cache_valid 200 60s;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass
}
}
An API cache key that adds tenant, version, and normalized format, names only the query args that matter, bypasses the cache without a tenant, and ignores an upstream Vary already covered by the key.
How freshness is decided and what must never be cached
That same proxy_ignore_headers directive decides who owns freshness. nginx checks X-Accel-Expires first, then Expires and Cache-Control. proxy_cache_valid applies only when none of those set a lifetime. A backend that sends Cache-Control: no-store or private, or Set-Cookie, prevents caching outright unless you ignore that header.
| Approach | Who sets the TTL | Strength | Risk |
|---|---|---|---|
| Honor upstream headers | Each endpoint's code | The service knows its own data's volatility | Teams emit inconsistent or missing headers |
proxy_ignore_headers plus proxy_cache_valid |
nginx config | One reviewed place for every TTL | nginx can cache data the backend marked uncacheable |
proxy_cache_valid works best as a fallback TTL table keyed by status code. A bare duration covers only 200, 301 and 302. Every other status needs an explicit entry, or any. Caching 404s and 5xx is a deliberate trade-off. Even a few seconds of cached errors shields a struggling backend from retries during an incident. The cost is that those errors keep being served after the upstream has recovered. Keep error TTLs much shorter than success TTLs.
The defaults are narrow but not safe. proxy_cache_methods covers GET and HEAD, and responses with Set-Cookie are skipped. nginx does not skip caching because a request carries Authorization, so one user's response can be served to another. For authenticated endpoints, either set proxy_no_cache and proxy_cache_bypass on the credential, or put the caller's identity in the key. Authenticated APIs have no safe default, and you have to make this choice per endpoint.
# http context
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:10m
max_size=1g inactive=10m;
upstream api_backend {
server 10.0.0.10:8080;
}
server {
listen 80;
server_name api.example.com;
proxy_cache api_cache;
proxy_cache_key "$scheme$host$request_uri";
# Default proxy_cache_methods is GET HEAD. Stated here so reviewers see it.
proxy_cache_methods GET HEAD;
# Fallback TTLs, used only when upstream headers set no lifetime
# (or when those headers are ignored, as in /v1/catalog/ below).
proxy_cache_valid 200 301 302 5m;
proxy_cache_valid 404 30s; # deliberate: absorbs repeated misses
proxy_cache_valid 500 502 503 504 5s; # short: shields a failing backend, recovers fast
add_header X-Cache-Status $upstream_cache_status always;
# Public, non-personal data: nginx owns freshness. Upstream Cache-Control,
# Expires and X-Accel-Expires are ignored. Set-Cookie still blocks caching.
location /v1/catalog/ {
proxy_ignore_headers Cache-Control Expires X-Accel-Expires;
proxy_pass
}
# Authenticated: nginx does not skip caching for Authorization on its own.
# A non-empty header means: don't serve from cache, don't store the response.
location /v1/account/ {
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
proxy_pass
}
}
Shows status-code fallback TTLs with short error caching, nginx-owned freshness via proxy_ignore_headers on a public endpoint, and explicit cache bypass on an authenticated endpoint.
Stale-while-revalidate and stampede protection
Continuing from authenticated endpoints, the remaining risk on cacheable endpoints is expiry. When a popular key expires, every concurrent request misses together and hits the upstream at the same moment. Two mechanisms address this.
Serving stale while one request refreshes
proxy_cache_use_stale lists when nginx may return an expired entry instead of waiting on the upstream:
errorandtimeoutcover connection failures.http_500,http_502,http_503andhttp_504cover upstream errors.updatingcovers the case where another request is already refreshing the entry.
Adding proxy_cache_background_update on makes the refresh a background subrequest, so the request that found the expired entry also gets the stale copy and does not wait. Background update depends on updating being in the list.
Upstreams can also control this per response. nginx honors the stale-while-revalidate and stale-if-error Cache-Control extensions, so the application can set stale windows per endpoint instead of one global policy.
Locking cold keys
A key with no cached copy has nothing stale to serve. proxy_cache_lock on sends only one request upstream to fill it. The others wait for that response, up to proxy_cache_lock_timeout. When the timeout passes, the waiting requests go to the upstream themselves, and their responses are not cached.
Trade-offs
- Queued requests wait longer. Their latency includes the leader's full upstream call.
- A slow upstream still produces a burst. If the upstream is slower than the lock timeout, every waiting request reaches it anyway.
- Stale data carries no signal. Clients cannot tell a response is stale unless you expose the cache status or an
Ageheader.
Some data must never be served stale: account balances, permission and role checks, and inventory counts. Keep these endpoints out of the cache entirely rather than shortening their stale window.
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m
max_size=1g inactive=10m use_temp_path=off;
upstream api_backend { server 10.0.0.10:8080; }
server {
listen 80;
server_name api.example.internal;
location /v1/catalog/ {
proxy_pass
proxy_cache api_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 60s;
# Serve the expired copy on upstream failure, or while another request refreshes it
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# Refresh in a background subrequest so no client waits (relies on 'updating' above)
proxy_cache_background_update on;
# Cold or evicted keys: one request fills the cache, the rest wait
proxy_cache_lock on;
# After 3s, waiters go upstream themselves and their responses are not cached
proxy_cache_lock_timeout 3s;
# Let clients see HIT, STALE, UPDATING, MISS, etc.
add_header X-Cache-Status $upstream_cache_status always;
}
# Balances, permissions, inventory: never cached, never served stale
location ~ ^/v1/(balances|permissions|inventory)/ {
proxy_pass
proxy_cache off;
}
}
A cacheable catalog endpoint that serves stale entries during refreshes and upstream failures, uses a cache lock for cold keys, and exposes cache status to clients, while staleness-sensitive endpoints bypass the cache entirely.
Invalidation without a built-in purge
Open-source nginx has no built-in purge directive, so every invalidation strategy is a workaround with different trade-offs.
Short TTLs limit how long wrong data can live. They require no tooling, but they also cap the hit ratio.
Versioned keys or URLs (a v segment, or a content version folded into proxy_cache_key) make new content get a new key immediately. The old entries are not removed. They sit on disk until inactive expires them or max_size forces eviction.
A controlled refresh path sets proxy_cache_bypass on an internal-only header such as $http_x_cache_refresh. A request carrying that header skips the cache lookup and goes to the upstream. Nginx still stores the response, so it overwrites the existing entry. The header must be stripped or ignored for public traffic, for example by accepting it only on a separate internal listen address. If you skip that step, any client can force cache misses.
Deleting cache files by key hash means computing the MD5 of the key and mapping it to the levels directory layout. It depends on that layout and on reproducing the key exactly, and it only affects one node.
A third-party purge module adds a real purge endpoint. The cost is building nginx with it and maintaining that build.
| Option | Consistency | Operational effort | Multi-node behavior |
|---|---|---|---|
| Short TTL | Bounded staleness | Minimal | Uniform, no coordination |
| Versioned keys | Immediate for new version | Low, disk churn | Uniform once clients switch |
| Refresh via bypass | Immediate per key | Moderate | Must hit every node |
| Delete files | Immediate per key | High, brittle | Per node |
| Purge module | Immediate per key | Custom build | Must hit every node |
With several proxies, every node keeps its own cache, so any explicit invalidation has to reach all of them. The same independence lowers hit ratios as you add nodes, because each node warms its own copy of every key. Consistent hashing at the load balancer routes each key to one node and avoids this.
Observability: verifying the key design with $upstream_cache_status
Before trusting the cache, make every decision visible. Add $upstream_cache_status to a response header such as X-Cache-Status with add_header ... always, so error responses carry it too, and to a dedicated log_format. That log line should include the same variables that make up proxy_cache_key, plus $upstream_response_time. Also log a flag derived through map that records whether Authorization or a session cookie was present, without logging the credential itself.
The values mean:
- MISS: the key was not in the cache.
- BYPASS:
proxy_cache_bypassmatched. - EXPIRED: the entry existed but was no longer fresh, so nginx refetched it.
- STALE: a stale entry was served because of
proxy_cache_use_stale. - UPDATING: a stale entry was served while another request refreshed it.
- REVALIDATED: a conditional request to the origin returned 304.
- HIT: the response was served fresh from the cache.
Use the logs to check the key design:
- Cardinality: count distinct key tuples per route. If a route has thousands of distinct keys and almost none repeat, the key is fragmenting. Look for unsorted query parameters, tracking parameters, or a header you didn't mean to include.
- Authenticated routes: any HIT where the credential flag is set should be investigated immediately. It means the key does not separate users.
- Eviction versus key design: a MISS on a key that appeared earlier, within its validity window, indicates eviction from
max_sizeorinactive. Fragmentation looks different: most keys never appear twice.
Clients also need to see staleness. Open-source nginx does not compute Age for cached responses. Have the upstream emit a generation timestamp header, which is stored with the cached response, so clients can tell how old the data is.
Worked example: public endpoint vs per-tenant endpoint
The two locations share one cache zone but differ on almost every other directive.
Public catalog location
proxy_cache_key "$request_method$host$uri$args_normalized": There is no tenant or credential component, because every caller receives the same bytes.$args_normalizedis the sorted query string built with amap, as described in section 2.proxy_cache_valid 200 60sandproxy_cache_valid 404 30s: A missing SKU is also a stable answer. Caching the 404 stops crawlers from repeatedly sending the same miss to the upstream.proxy_cache_use_stale error timeout updating http_502 http_503,proxy_cache_background_update onandproxy_cache_lock on: During an outage or a refresh, clients receive stale data. Only one request per key goes upstream.
Per-tenant location
proxy_cache_key "$tenant_id$request_method$uri$args_normalized":$tenant_idcomes fromauth_request_set. It is not taken from a client header that a caller could forge.proxy_cache_bypass $tenant_missingandproxy_no_cache $tenant_missing $cookie_session: If identity is unresolved, nginx neither reads from nor writes to the cache.proxy_cache_valid 200 10s: Only successful responses are cached. A 404 for one tenant can be an authorization artifact, so it should not be stored.proxy_cache_use_stale error timeout: There is noupdatinghere, so tenants do not receive data that is stale beyond the TTL during normal operation.
Illustrative estimate (assumed inputs)
Catalog: Assume 2,000 req/s spread over 5,000 keys. With the lock, the upstream sees at most about one fetch per key every 60 s. That is 5,000 ÷ 60 ≈ 83 req/s, for a hit ratio of roughly 96%.
Tenant endpoint: Assume 200 req/s spread over 8,000 tenant-scoped keys. Each key gets about one request every 40 s, which is longer than its 10 s TTL, so the hit ratio is near zero. Caching this endpoint only pays off for keys that individual tenants request far more often than the TTL.
When to use it, when to avoid it, and alternatives
Proxy caching fits public or tenant-scoped data that is read far more often than it changes and can tolerate staleness up to a known bound. Examples include catalog listings, configuration documents, and aggregate dashboards. The cache key stays small, a stale answer is merely old rather than wrong for the caller, and one layer absorbs load for every service behind it.
Avoid it for per-user data, balances, entitlements, and permission checks. A key mistake there leaks data across users, and a stale answer can grant access that was revoked. Also avoid it where a client writes and then expects to read its own write, or where responses are personalized enough that keys rarely repeat.
| Option | Best for | Main cost |
|---|---|---|
| nginx proxy_cache | Shared read-heavy endpoints | Key design, purge machinery, per-node state |
| In-process cache | Hot data inside one service | Per-instance inconsistency, memory pressure |
| Shared key-value store | Cross-instance app caching | Extra network hop, invalidation code in every service |
| Client/CDN via Cache-Control | Public, cacheable-by-anyone data | Little control after the response leaves your servers |
| Precomputed responses | Expensive, predictable queries | Build pipeline, storage, refresh scheduling |
Proxy caching can spare each team from building its own caching layer, and it removes backend work without changing application code. That saving is real but not free. Someone has to own the key design, build invalidation across every nginx node, and run leak tests whenever an endpoint adds a header or a new auth scheme. Budget for that as ongoing work, not a one-time setup.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nginx proxy_cache api, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.