An nginx proxy cache has several independent decisions: identifying the stored response, deciding whether it may be reused, deciding whether a new response may be stored, and handling an expired copy. Treating those decisions as one TTL setting makes failures difficult to explain. Verify the resulting request path and bytes, not merely that nginx accepts the configuration.
Separate storage from freshness
Proxy_cache_path names the disk storage and a shared-memory keys_zone for metadata; proxy_cache enables that zone for requests. The metadata size does not bound response-body storage. Max_size is managed by cache maintenance rather than an instantaneous disk quota, while inactive retention is not the response’s freshness lifetime. An expired response can remain stored and useful for revalidation.
The default key is $scheme$proxy_host$request_uri. Request_uri already includes the query string, so appending $is_args$args repeats it. Do not casually substitute a normalized URI or omit representation-changing inputs. GET and HEAD share the default cache behavior because HEAD conversion is enabled; disabling that conversion requires accounting for the request method in the key.
Verify the active response policy
Proxy_cache_valid supplies configured lifetimes by response status, but processed origin freshness headers have higher priority. X-Accel-Expires has its own precedence; Cache-Control and Expires apply when it is absent. Ignoring those headers changes the contract. In the exercise, origin max-age one overrides configured validity of sixty seconds, while no-store prevents response storage.
Proxy_cache_revalidate enables conditional requests for expired items using validators such as ETag and Last-Modified. A matching 304 can preserve the body and refresh metadata; a changed 200 supplies replacement bytes. This does not imply asynchronous refresh. Background updating is a separate directive and also needs permission to serve stale content during updating.
When it breaks
Proxy_cache_bypass prevents reading from cache; proxy_no_cache prevents saving the new response. A route requiring both behaviors needs both decisions expressed. Bypassing once also does not purge an older entry. The fixture pairs the directives with a laboratory header and proves that the original cached response remains available afterward. That header is a test switch, not an authentication policy.
Proxy_cache_use_stale http_500 permits an existing stale response after an upstream 500. It cannot help a completely cold key, and the directive does not itself specify a maximum stale duration. An origin stale-if-error window has lower priority than these directive parameters, so adding that header does not cap this setting. Decide whether old content is acceptable before enabling it.
Otherwise a successful-looking response can conceal an origin failure or keep obsolete data visible. Record the upstream outcome, nginx cache status, and delivered body together. A syntax check cannot establish account isolation, and a single warm hit cannot prove behavior during concurrent cold loads. Cache locking, retention under pressure, and multi-worker behavior require their own verification.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.11 or newer. This ran on macOS with clang 21.0.0, Python 3.14.4, and nginx 1.30.4. The commands build an isolated HTTP-only test binary without installation or sudo. They omit rewrite and gzip modules to avoid their external dependencies; this is not a TLS deployment recipe.
nginx_lab=$(mktemp -d)
curl --fail --location --output "$nginx_lab/nginx.tar.gz" https://nginx.org/download/nginx-1.30.4.tar.gz
tar -xzf "$nginx_lab/nginx.tar.gz" -C "$nginx_lab"
cd "$nginx_lab/nginx-1.30.4"
./configure --prefix="$nginx_lab/install" --without-http_rewrite_module --without-http_gzip_module
make -j2
export NGINX_BIN="$nginx_lab/nginx-1.30.4/objs/nginx"
Run the Python block with python3 in that shell. It creates its own configuration and cache directory, checks the configuration, and starts only loopback services on selected ports. Two short waits allow real cache expiry. It shuts down its own nginx process and origin, then removes only the temporary runtime directory. Source downloads need the network; requests in the experiment remain local.
import hashlib
import os
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import signal
import socket
import subprocess
from tempfile import TemporaryDirectory
from threading import Thread
import time
state = {"body": b"A", "fail": False}
seen = []
class Origin(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
tag = '"' + hashlib.sha256(state["body"]).hexdigest() + '"'
conditional = self.headers.get("If-None-Match")
if self.path == "/short" and state["fail"]:
status, body = 500, b"failure"
elif conditional == tag:
status, body = 304, b""
else:
status, body = 200, state["body"]
directive = "max-age=1" if self.path == "/short" else "max-age=60"
if self.path == "/no-store" or status == 500:
directive = "no-store"
seen.append((self.path, status, conditional))
self.send_response(status)
self.send_header("Cache-Control", directive)
if status != 500:
self.send_header("ETag", tag)
if status != 304:
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
origin = HTTPServer(("127.0.0.1", 0), Origin)
worker = Thread(target=origin.serve_forever)
worker.start()
try:
with TemporaryDirectory(prefix="nginx-cache-lab-") as directory:
root = Path(directory)
with socket.socket() as reservation:
reservation.bind(("127.0.0.1", 0))
port = reservation.getsockname()[1]
config = root / "nginx.conf"
config.write_text(f"""
pid nginx.pid;
error_log stderr error;
events {{ worker_connections 64; }}
http {{
access_log off;
proxy_cache_path "{root}/cache" keys_zone=lab:1m max_size=10m;
server {{
listen 127.0.0.1:{port};
location / {{
proxy_pass http://127.0.0.1:{origin.server_port};
proxy_cache lab;
proxy_cache_valid 200 60s;
proxy_cache_revalidate on;
proxy_cache_use_stale http_500;
proxy_cache_bypass $http_x_lab_bypass;
proxy_no_cache $http_x_lab_bypass;
proxy_connect_timeout 2s;
proxy_read_timeout 2s;
add_header X-Lab-Cache $upstream_cache_status always;
}}
}}
}}
""", encoding="utf-8")
command = [os.environ["NGINX_BIN"], "-p", str(root) + "/",
"-c", str(config), "-e", "stderr"]
checked = subprocess.run(command + ["-t"], capture_output=True, text=True)
assert checked.returncode == 0, checked.stderr
process = subprocess.Popen(command + ["-g", "daemon off; master_process off;"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "nginx exited during startup"
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
break
except OSError:
if time.monotonic() >= deadline:
raise TimeoutError("nginx did not become ready")
time.sleep(0.05)
def get(path, expected, body, bypass=False):
connection = HTTPConnection("127.0.0.1", port, timeout=3)
try:
connection.request("GET", path, headers={"X-Lab-Bypass": "1"} if bypass else {})
response = connection.getresponse()
actual = (response.status, response.getheader("X-Lab-Cache"), response.read())
assert actual == (200, expected, body), actual
print(path, expected, body.decode())
finally:
connection.close()
get("/long", "MISS", b"A")
get("/long", "HIT", b"A")
state["body"] = b"B"
get("/long", "BYPASS", b"B", bypass=True)
get("/long", "HIT", b"A")
assert sum(path == "/long" for path, _, _ in seen) == 2
get("/short", "MISS", b"B")
time.sleep(2.1)
get("/short", "REVALIDATED", b"B")
assert seen[-1][1:] == (304, '"' + hashlib.sha256(b"B").hexdigest() + '"')
state["fail"] = True
time.sleep(2.1)
get("/short", "STALE", b"B")
assert seen[-1][1] == 500
state.update(body=b"C", fail=False)
get("/short", "EXPIRED", b"C")
get("/short", "HIT", b"C")
get("/no-store", "MISS", b"C")
get("/no-store", "MISS", b"C")
assert sum(path == "/no-store" for path, _, _ in seen) == 2
finally:
if process.poll() is None:
process.send_signal(signal.SIGQUIT)
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors
finally:
origin.shutdown()
worker.join()
origin.server_close()
The long-lived route reports MISS A, HIT A, BYPASS B, then HIT A. Only two requests reached that origin path, and B was not saved by the bypass request. The short-lived route reports MISS B and, after expiry, REVALIDATED B; the origin log confirms a 304 and the correct conditional ETag.
With an injected origin 500 it serves STALE B. Once the origin recovers with C, EXPIRED C replaces the body and the following request is HIT C. Two no-store requests both remain MISS. These are real nginx decisions for this single-process fixture, not evidence for background refresh, distributed invalidation, production authorization, disk-pressure handling, or every stale-policy combination.
Further reading
- Cache headers are a contract with two clocks
- Validators decide whether a stored copy is still the answer
Sources
- nginx proxy module, keys, header precedence, revalidation, bypass, storage, and stale directives, checked 2026-09-07.
- nginx build configuration, build prefix and optional module dependencies, checked 2026-09-07.
- nginx content caching guide, disk storage, shared metadata, and cache management, checked 2026-09-07.
- RFC 9111: stale responses, freshness and permitted stale reuse, checked 2026-09-07.