nginx-cache-turbo-module
August 31, 2026 · View on GitHub
A built-in page cache for nginx. Think of it as a tiny Varnish that lives inside nginx — no extra daemon, no second port, no Lua.
Writeup: nginx-cache-turbo — a built-in page cache. Ships in the deb.myguard.nl nginx/angie stack as
libnginx-mod-http-cache-turbo(nginx) /angie-module-http-cache-turbo(Angie) — see Building & the stack.
CI
One pull_request: entry point. ci.yml orchestrates the six PR member
workflows, so a change gets exactly one run of each. CodeQL is scheduled or
manual, Valgrind is manual, and the long testkit/deep workflows keep their own
scheduled/manual entry points. Lane measurements and the command to re-derive
them live in ci.yml's header comment.
| Workflow | What it gates |
|---|---|
| Lint | the ci/linter/ gate: nginx conventions, ast-grep, shell, Python, Perl, YAML/actionlint/zizmor, spelling, the workflow-policy checks, and this module's own R7 shm-lock and suite-integrity checkers |
| Build&Test | validation (shellcheck, ruff, sync-stamp, port bands), build, the Test::Nginx preset suite, and the runtime driver |
| Security Scanners | flawfinder >=4, tracked-policy clang-tidy, advisory GCC -fanalyzer, and semgrep >=WARNING over src/ |
| Fuzzing | replay of every recorded regression, then a time-boxed run of the five libFuzzer targets |
| CodeQL | CodeQL over the module TU; keeps its own monthly schedule: |
| A/UBSan | ASan+UBSan single-process and multi-worker soaks, static --add-module build |
| CI Deep | monthly: long fuzz campaigns, memcheck soak, the nginx/angie compatibility matrix |
| Valgrind | on-demand 60s memcheck soak; the monthly deep workflow carries the scheduled 600s soak |
| Testkit | the nginx-module-testkit prober leg: per-request allocation neutrality read out of the cycle pool, asserted in both directions (a staged tree must assert, an unstaged one must SKIP cleanly) |
| Testkit Valgrind | weekly: the same prober scenarios as Testkit, staged as a plain DEBUG build and run under valgrind memcheck instead of the probe's own fd/pool-delta accounting, asserted in both directions the same way |
| Bump versions | weekly refresh of the pinned nginx/angie versions and verified archive digests |
The testkit prober leg
nginx-module-testkit is
a separate harness repo. Its consumer-cache-turbo scenario boots a real nginx
with this module and testkit's reference probe loaded side by side, then reads
the probe's JSON to assert per-request allocation neutrality: two post-drain
quiescent snapshots taken around one extra served request must show identical
cycle-pool counters (cycle_used, cycle_blocks, cycle_large), worker fd
count and master fd count.
Run it locally against a sibling checkout of the harness:
git clone https://github.com/myguard-labs/nginx-module-testkit ../nginx-module-testkit
ci/tools/testkit-stage.sh # one configure binding BOTH modules
ci/tools/testkit-run.sh # the scenario, expecting real assertions
ci/tools/testkit-run.sh --expect-skip --version 1.30.4 # the negative control
testkit-stage.sh autodetects ../nginx-module-testkit; override with
--testkit DIR or $TESTKIT_ROOT. It refuses to report success if a staged
.so is older than the sources it was built from — a scenario loading a stale
object would report its oracles green against code that was never built.
What it proves. That the HIT path frees everything it allocates per
request: no cache-entry-ref, header-copy or connection leak surviving repeated
serves. The claim is non-vacuous by construction — the scenario first asserts a
genuine X-Cache: HIT, so a pass-through filter that allocates nothing cannot
score as "allocation-neutral".
What it does not prove. Nothing about concurrency (the scenario runs
worker_processes 1), and nothing about allocations that are correctly
per-connection or per-cycle rather than per-request. It is a leak lens, not
a correctness lens; response-content correctness stays with the runtime suite.
Under sanitizers
--sanitizer stages and runs a second tree built with
-fsanitize=address,undefined -fno-sanitize-recover=undefined, applied to the
nginx binary and both modules:
ci/tools/testkit-stage.sh --sanitizer # .build/nginx-<ver>-testkit-asan
ci/tools/testkit-run.sh --sanitizer
The sanitized and plain trees are separate directories, so both legs can be staged at once and neither stage destroys the other. Neither script trusts the flag: both grep the built objects for the sanitizer runtime and refuse to report success on a tree that does not carry it — a leg that silently ran unsanitized would pass while observing nothing. The plain leg is guarded in the other direction too, since ASan moves the very allocation counters the oracles read.
detect_leaks is deliberately off (testkit's default). nginx never frees its
configuration pool, so LeakSanitizer reports the whole config parse as leaked and
turns nginx -t into a bail-out before any oracle runs. The workaround people
reach for — an LSan suppression file — is rejected here: leak:ngx_create_pool
and leak:main were both disproven by negative control, and they suppress real
module leaks along with the noise. This leg targets what ASan and UBSan catch at
the moment it happens.
Under valgrind (weekly)
--valgrind stages and runs a third tree: a plain --with-debug build with
nginx's own pool poisoner on (NGX_DEBUG_PALLOC=1), and no sanitizer
flags — valgrind memcheck and ASan both intercept the allocator, and running
one under the other is unsupported and produces noise, not signal:
ci/tools/testkit-stage.sh --valgrind # .build/nginx-<ver>-testkit-valgrind
ci/tools/testkit-run.sh --valgrind
Same separate-directory, same-two-directions discipline as --sanitizer
above: a distinct -testkit-valgrind stage tree, verified by grepping the
built objects to confirm they carry no ASan/UBSan runtime, run in both
directions (a staged tree must assert under memcheck, an unstaged one must
still SKIP cleanly under the same flags). The flag set exported around each
scenario — PROBER_VALGRIND's exact memcheck invocation and
PROBER_TIMEOUT_SCALE=40 — is copied verbatim from testkit's own
ci/prober/valgrind-scenarios.sh, not re-derived; see that script's header
in the nginx-module-testkit
repo for why each flag is there. This leg runs weekly
(Testkit Valgrind), not on every
PR: memcheck is 20-50x slower than native, so the whole scenario tree under
it is minutes, not seconds.
Scenarios authored in this repo
ci/prober-scenarios/ holds scenarios that encode cache-turbo-specific
knowledge and have no place in a generic harness. testkit-run.sh looks there
first, then in testkit's own directory, so a local scenario needs no change to
the harness repo:
ci/tools/testkit-run.sh --sanitizer -- l2-cross-instance-fill
l2-cross-instance-fill boots a second nginx with a cold L1 against the
same Redis and asserts it serves an object the first instance cached, without a
second origin visit — then stops it and checks its logs for a sanitizer report.
It starts its own Redis, refuses to run if that port is already occupied (a
leftover fixture would serve stale keys and make a "cold" cache silently warm),
and reaps both the Redis and the second instance on the way out. It SKIPs
cleanly where redis-server is absent.
Why both directions are checked. The scenario's requires gate SKIPs when
no module .so is staged, and 1..0 # SKIP is a passing TAP plan. A
permanently-skipping leg and a green leg are indistinguishable in a job summary
— which is exactly why this scenario existed, finished, for a month without
ever running. So CI asserts both that a staged tree produces real assertions
and that an unstaged one still skips cleanly.
Linting
The same checks run locally and on the PR, through one entry point — a CI-only copy would drift from the hook and the two would stop agreeing:
ci/linter/install-linters.sh # once per clone
git config core.hooksPath .githooks # NOT `pre-commit install`
ci/linter/run-all.sh # whole tree; the hook does --staged
Sixteen checkers behind run-all.sh, exit 0 clean / 1 findings / 2 a tool
is missing — never a silent skip. What each one covers, which are advisory
rather than blocking and why, and how to prove one still bites, is in
ci/linter/README.md. ci/linter/selftest.sh holds the
negative controls for the gate itself, because a checker that has quietly become
a no-op prints exactly the same clean line as one that passes.
Contents
- The idea in 30 seconds
- The tiers: L0 → L1 → L2
- Mixing with nginx's native cache (
proxy_cache) - Quick start
- What it will and won't cache
- CMS backends (
cache_turbo_backend) - The cache key
- Presets (pick a vibe, skip the knobs)
- Microcaching (1-second TTL for APIs and PHP-FPM)
- Long-tail URLs: reach for
cache_turbo_min_usesfirst - Scan-resistant eviction:
cache_turbo_scan_resistant - Admission control:
cache_turbo_zone ... admission=on - Zone sizing under LRU pressure
- What autotune actually tunes
- Full example (the works)
- Every directive in one place (full syntax)
- Directive synopsis
- Monitoring (Prometheus + Grafana)
- Redis L2 (shared cache)
- Building & the stack
- Benchmarking
- License
The idea in 30 seconds
Your backend (PHP, Node, whatever) is slow. The same pages get requested over
and over. So: the first time someone asks for /blog/post-42, nginx fetches it
from the backend once, keeps a copy in shared memory, and serves that copy
to everyone else. Backend barely gets touched.
The clever part is what happens when a copy gets old:
- fresh (young copy) → serve it instantly, backend never woken.
- stale (past its TTL but not ancient) → still serve the old copy immediately, and one request in the background goes and gets a new one. Nobody waits, and your backend doesn't get hammered by a thundering herd.
- expired (too old) → treat as a miss, fetch fresh.
That "serve old now, quietly refresh one copy" trick is called stale-while-revalidate (SWR). It's the whole point.
And when that background refresh fails — the origin returns a 5xx or times
out — cache-turbo keeps the good copy and serves it instead of surfacing the
error (stale-if-error), automatically, no config. An origin
Cache-Control: stale-if-error=N extends that grace past the normal stale
window (served as X-Cache: STALE-IF-ERROR). The one thing it can't do is
shield a page it never cached, so warm critical URLs ahead of an outage.
The stale-if-error rescue is pre-flush only, and only for a response that opens with an origin error status. It replaces that error response before any byte of it reaches the client — for that path, no config is needed.
A mid-body death is a different failure shape: the origin answers with a
normal 200 and then dies partway through the body (headers already sent, body
cut short). In production this module has no reliable in-process signal
that this happened — a closed upstream connection, an unmet
Content-Length, or a late-set connection error can each also mean "this is
legitimately chunked/EOF-framed" or arrive too late relative to the filter
that would need to act on it — so there is currently no automatic rescue for
it. What exists is a deterministic test-only fault path, gated behind the
build-time TEST_FAULTS flag and the cache_turbo_test_midbody_abort
directive (used by this module's own test suite to force a mid-body failure
predictably); it is not compiled into a normal build and there is no way to
turn it on in production. Even inside that test path, the rescue only splices
in the cached body when doing so preserves response framing — it checks the
already-serialized Content-Length against the cached snapshot's length and
declines rather than risk a client hang or a desynced connection when they
don't match. And like the error-status rescue, it is pre-flush only: once a
byte of the truncated body has gone out, the client already holds an
unrecoverable partial response and there is no way to un-send it. In that
pre-flush window the client-visible status stays whatever the origin sent
(200) — headers were already serialized before the body filter can act, so
only the body would ever be replaced. This window closes on the first flushed
buffer, which for a small/buffered response is typically the whole body at
once.
Optional extras: a shared Redis tier so a cluster of nginx boxes share one cache, tag-based purging, cache warming, and live auto-tuning.
The tiers: L0 → L1 → L2
cache-turbo is layered, fastest first. A request walks down only until something answers:
┌────────────────────────── one nginx box ──────────────────────────┐
client → │ L1: shared-memory page cache ──miss──▶ L2: Redis (optional) │ ──miss──▶ origin
│ (RAM, sub-millisecond) (shared by the fleet) │ (your backend)
└────────────────────────────────────────────────────────────────────┘
- L1 — shared memory (always on). The
cache_turbo_zone. A hit here is RAM-speed and never leaves the worker. This is where SWR / single-flight / LRU eviction live. Per-box. - L2 — Redis (optional,
cache_turbo_redis). A tier shared by every nginx box. Touched only on an L1 miss (oneGET) and on store (async write-through) — never on an L1 hit. So one box warming a page warms the whole fleet, and a restarted box refills from Redis instead of stampeding the origin. The L2 key — and every purge index that points at it (variant index, tag index) — is kept for the full retention window,max(stale window, stale-if-error window), so an entry that is still serveable stays purgeable for exactly as long. (An index that expired before its object would leave content that is still served but that a purge can no longer find.) - origin — your backend. Reached only when both L1 and L2 miss. SWR + the single-flight lock (and the cross-node Redis lock) keep origin hits to roughly one per stale cycle even under a stampede.
Where's "L0"? When you put cache-turbo in front of nginx's own
proxy_cache(next section), cache-turbo becomes the L0 in front of that on-disk L1 — see below.
Mixing with nginx's native cache (proxy_cache)
You can run cache-turbo together with proxy_cache / fastcgi_cache — they sit
at different layers, so they stack cleanly:
cache-turbo (ACCESS phase, shm) proxy_cache (content phase, disk)
request ─▶ ┌──────────────────────────────┐
│ L1 lookup │
│ ├─ HIT/STALE → serve, DONE ─┼──▶ (proxy_cache never runs)
│ └─ MISS ────────────────────┼──▶ proxy_pass + proxy_cache ─▶ origin
└──────────────────────────────┘ │
▲ │
└──── captures the response ◀────┘ (stores it in shm)
On a cache-turbo hit the request is finalized in the ACCESS phase and never
reaches proxy_pass, so proxy_cache is skipped entirely. On a miss the
request flows through proxy_cache as usual, and cache-turbo just captures
whatever comes back (disk-hit or origin) into its shm. So cache-turbo is an L0
in front of proxy_cache's disk L1.
Two sane patterns:
# A) split by content — shm for hot HTML, disk for big media
location / {
cache_turbo ct; # shm front
cache_turbo_valid 60s;
proxy_pass http://app;
}
location /media/ {
cache_turbo off; # let the native disk cache handle bulk
proxy_cache disk;
proxy_pass http://app;
}
# B) stack both on the same location — shm L0 over a big disk L1
location / {
cache_turbo ct;
cache_turbo_valid 30s;
proxy_cache disk; # survives reloads, holds more than RAM
proxy_cache_valid 200 10m;
proxy_pass http://app;
}
Things to know when stacking:
- Independent storage + purge. Same page may live in shm and on disk;
purging cache-turbo does not purge
proxy_cache(and vice-versa). Keep the disk TTL ≥ the shm TTL so the layers don't fight. - No header clash. cache-turbo strips the native cache's
Age,X-CacheandX-Cache-Statusbefore storing, so an L1 hit never replays a frozen age/status. cache-turbo's ownX-Cache: HIT/STALEis the source of truth; readproxy_cache's state via$upstream_cache_statusif you want it. - Layered staleness. A cache-turbo SWR refresh goes through
proxy_cache, which may serve its stale. If that matters, keepproxy_cacheTTL ≤ cache-turbo's, or disable proxy stale (proxy_cache_use_stale off). - Rule of thumb: don't double-cache the same content. Use cache-turbo for
what benefits from shm speed + SWR + Redis L2 + tag purge; use
proxy_cachefor a huge on-disk corpus that won't fit in RAM.
When to pick which
proxy_cache is a fine, battle-tested cache. An honest side-by-side:
| cache-turbo | nginx proxy_cache | |
|---|---|---|
| Store / phase | shared memory, ACCESS phase | disk, content phase |
| Throughput | +23–37 % small/medium (bench) | baseline |
| Stale-while-revalidate + stale-if-error | on by default | manual (proxy_cache_use_stale) |
| Dogpile / single-flight | per-box and cross-fleet (Redis lock) | per-box (proxy_cache_lock) |
| Shared / distributed cache | Redis/memcached L2 across the fleet | per-box disk, every node cold alone |
| One config for php-fpm and APIs | same directives (fastcgi_pass + proxy_pass) | separate fastcgi_cache / proxy_cache |
| Tag purge · auto-Vary · CMS auto-classify · Prometheus | built in | none |
| Range requests on a HIT | honoured (Range: answered 206, same as a MISS) | honoured |
| Survives reload / restart | reload yes, restart no — shm cleared on restart (Redis L2 softens) | yes, persists on disk |
| Capacity | bounded by RAM | huge on-disk corpus |
| Built into nginx | no — dynamic module | yes, nothing to install |
| Maturity | newer | a decade of edge cases |
Pick: hot HTML and dynamic apps → cache-turbo. A giant cold / long-tail on-disk archive →
proxy_cache. In doubt, stack them — cache-turbo as the RAM L0 over aproxy_cachedisk tier (above).Not a differentiator: caching dynamic / php-fpm / API responses —
fastcgi_cache/proxy_cachedo that too. cache-turbo's edge is the unified config plus the single-flight + SWR that make aggressive 1-second microcaching genuinely safe (the backend sees ~one request per second per key, not a stampede).
Behind a CDN / multi-tier caching
When cache-turbo runs as a shared cache behind a CDN (Cloudflare, Fastly,
Akamai …), the origin often needs three different TTLs: one for the browser,
one for the CDN edge, and one for this shared cache. Plain Cache-Control can
only carry two (max-age for private caches, s-maxage for shared). RFC 9213
adds targeted cache-control headers for exactly this, and with
cache_turbo_cache_control honor cache-turbo reads them at a higher priority
than Cache-Control:
| Priority | Header | Emitted by | TTL token |
|---|---|---|---|
| 1 (highest) | Surrogate-Control | Fastly, Akamai | max-age |
| 2 | CDN-Cache-Control | Cloudflare (RFC 9213) | s-maxage > max-age |
| 3 | Cache-Control | everyone | s-maxage > max-age |
| 4 (lowest) | Expires | legacy | absolute date |
So an origin can say:
Cache-Control: max-age=60 # browser: 60s
CDN-Cache-Control: max-age=600 # this shared cache (and the CDN): 10 min
Surrogate-Control: max-age=3600 # Fastly edge specifically: 1 h
and cache-turbo stores it for 1 hour (the Surrogate-Control value),
ignoring the 60s browser TTL. Semantics:
- TTL precedence — the highest-priority present header wins (table above).
Only active under
cache_turbo_cache_control honor. no-storeveto — a targetedno-store/private/max-age=0refuses the shared store the same way a plainCache-Control: no-storedoes. An origin can therefore keep a page out of this cache while still letting the browser cache it.- Stripped before store — both targeted headers are removed before the entry
is stored (like
Age), so a cached HIT never replays them downstream to the browser or a next cache tier — you (the shared cache) are their intended consumer. ignoremode —cache_turbo_cache_control ignorediscards the targeted variants too, alongsideCache-Control.
See also:
cache_turbo_cache_controlfor the full honor/ respect/ignore semantics, and Mixing with nginx's native cache for stacking withproxy_cache.
Purge-syncing the CDN: cache_turbo_surrogate_key
The headers above are about freshness flowing down to this cache. The
other direction is invalidation: when the origin changes a page you want to
drop it from both cache-turbo and the CDN edge in one go. Fastly (and any
edge speaking the surrogate-key spec) purges by tag — you tell the edge which
tags an object carries via a Surrogate-Key response header, then later issue a
"purge everything tagged X".
cache-turbo already indexes objects by tag for its own purge-by-tag
(Redis L2). cache_turbo_surrogate_key on mirrors the
same cache_turbo_tag list back downstream as a Surrogate-Key header,
so the fronting CDN keys the edge copy on the identical tags and the two stay
purge-synced:
location / {
cache_turbo main;
cache_turbo_valid 1h;
cache_turbo_tag $upstream_http_x_cache_tags; # origin names the tags
cache_turbo_surrogate_key on; # re-emit them to the CDN
proxy_pass http://backend;
}
Semantics:
- MISS and HIT. The header rides every response for the representation,
not only the one that populated the cache. A CDN POP whose own copy expired or
was evicted refills from a cache-turbo HIT; if that hit carried no
Surrogate-Keythe edge object would be untagged and would survive a later tag purge, leaving stale content served. - Same parsing as the tag index — whitespace/comma split, deduped, and bounded to the same per-tag length and count caps, so a hostile/buggy origin can't blow up the emitted header.
- No Redis required. Emitting the CDN header does not need cache-turbo's
own L2 tag index — you can front a pure-L1 cache with a tag-purging CDN. (If
you also want cache-turbo's own purge-by-tag, add
cache_turbo_redis.) - Generated, not stored. While the directive is
onthe emittedSurrogate-Keyis kept out of the stored blob and regenerated live fromcache_turbo_tagon each serve — so a HIT emits exactly one header line and always the current tag set. With the directiveoff, an origin-suppliedSurrogate-Keyis ordinary response metadata: it is stored with the entry and replayed on every HIT.
Quick start
load_module modules/ngx_http_cache_turbo_module.so;
http {
# one shared-memory zone, 256 MB, named "ct"
cache_turbo_zone name=ct 256m;
server {
listen 80;
location / {
cache_turbo ct; # turn caching on, use zone "ct"
cache_turbo_valid 10s; # a copy is "fresh" for 10s
proxy_pass http://127.0.0.1:8080; # your slow backend
}
}
}
That's the whole config. With just cache_turbo ct; the default key is the
Host header (r->headers_in.server — the validated Host, or matched
server_name) plus the raw unparsed request URI (path + raw query string, no
decoding, no normalization) — not the nginx $host$uri$query_string
expression, since $uri is decoded/normalized and unparsed_uri is not; the
closer nginx-variable spelling is $host$request_uri. Vhosts don't collide,
but by default tracking params and arg order do matter. To enable the
automatic stripping of tracking params (utm_*,
fbclid, …, plus sid, sessionid, tmp_*) and order-insensitive matching,
you must explicitly set the key:
location / {
cache_turbo ct;
cache_turbo_key $host$uri$cache_turbo_normalized_args; # explicit — enables param stripping
cache_turbo_normalize_strip sid sessionid "tmp_*"; # already built in
proxy_pass http://127.0.0.1:8080;
}
Curl it twice and look at the X-Cache header:
$ curl -s -o /dev/null -D- localhost/ | grep -i x-cache # 1st time: nothing (a miss)
$ curl -s -o /dev/null -D- localhost/ | grep -i x-cache
X-Cache: HIT # 2nd time: served from RAM
Use GET (
-s -o /dev/null -D-), notcurl -sI. AHEADresponse is never stored, so-Ican never show you aHITno matter how many times you run it.
X-Cache: HIT = fresh from cache. X-Cache: STALE = old copy while a refresh
runs. No header = it went to the backend (a miss).
What it will and won't cache
By default it stores a 200 OK to a bodyless GET (never a HEAD — that would
store an empty body). A GET or HEAD with a non-empty Content-Length or
chunked request body bypasses both cache lookup and storage, because the body
can change application dispatch without appearing in the cache key. Requests
carrying X-HTTP-Method-Override, X-Method-Override, or X-HTTP-Method do the
same. Request-body bytes and override-header fields are not stripped; they
remain available to the configured upstream. You can also cache redirects
and negative responses by giving their status codes a TTL:
cache_turbo_valid 30s; # the default / 200 TTL
cache_turbo_valid 301 302 308 1h; # cache redirects
cache_turbo_valid 404 410 1m; # negative caching
cache_turbo_valid 0; # "cache forever" (stays fresh, never expires)
A TIME of 0 means cache forever: the copy stays fresh indefinitely (it
is never served stale and never re-fetched on its own — purge it explicitly to
update). Internally this is a long finite TTL, so it still works across the L2
(Redis/memcached) tier like any other entry.
cache_turbo_validreplaces, it does not merge. If a nestedlocationsets anycache_turbo_validof its own, it discards the entire set inherited from the parent (all status-code TTLs included) — standard nginx array-merge semantics. Re-state every status line you still want in the nested block; don't assume the parent's301/404TTLs carry through once you add a child rule.
And it refuses to cache anything that looks per-user, so you don't accidentally serve Alice's logged-in page to Bob:
- request had an
Authorizationheader → not cached and not served from cache (an anonymously-primed copy is never handed to a credentialed request) - response sets a cookie (
Set-Cookie) → not cached - response says
Cache-Control: private | no-store | no-cache | max-age=0 | s-maxage=0→ not cached 206 Partial Content→ never cached (the cache key has noRange, so a stored partial could be served for a different/whole range)- a response that arrives already compressed from the origin (a non-identity
Content-Encodingset before our header filter, i.e. the upstream itself compressed it) → not cached by default. Setcache_turbo_key_encoded_origin onto opt in: supported encoding classes are keyed separately and the serve-side guard checks that the client accepts the stored coding; HIT and stale-if-error responses restore the exactgzip,br, orzstdContent-Encodingfrom the validated cache stamp. Without that opt-in, the module caches the identity body and lets the local gzip/zstd/brotli filter re-encode per client; replaying an origin-pre-compressed body encoding-blind would break clients that negotiated a different coding. (Locally-compressed responses are fine — our body filter runs above the compressors and captures identity.)
Hop-by-hop / framing headers (Connection, Transfer-Encoding,
Content-Length, Content-Encoding, Set-Cookie, Date, Server, …) are
stripped before storing and rebuilt on the way out, so a cached response is
still well-formed.
The same filter runs again on the way out of L2. A shared Redis/memcached
tier is not part of nginx's trust boundary — anyone who can write to it chooses
the bytes a HIT replays — so a restored entry is re-checked before its headers
reach the response: a field name that is not an HTTP token, a value containing
CR, LF or NUL, or any name on the strip list above is dropped, and a blob whose
status is outside 100..599 or whose stale window is shorter than its fresh
window is rejected outright (the request falls through to the origin). A cached
copy written by this module never trips any of these; one that does was not
written by this module.
The Date is re-emitted as a stable timestamp for the cached
representation (it does not advance on every hit), and an Age header reports
how long the copy has been cached — the two stay mutually consistent (RFC 9111).
It also honours a few request Cache-Control directives: no-cache /
max-age=0 (and Pragma: no-cache) force a revalidation against the origin
(a force-refresh); no-store runs the request to the origin and does not
store the response; and only-if-cached answers 504 Gateway Timeout when the
page is in neither L1 nor L2, instead of contacting the origin. max-age=N
and min-fresh=N bound which cached representation the client will accept;
max-stale=N permits at most N seconds of staleness and bare max-stale
permits any staleness within the cache's own serveable window. A malformed
valued max-stale grants no stale tolerance.
Letting the origin decide (cache_turbo_require_header)
Everything above is the module deciding from the HTTP it can see. Some origins
answer things HTTP cannot separate: a GraphQL endpoint serves a read query
and a write mutation on the same URI and method, and reports errors as
200 OK with an errors member in the body. Nothing at the HTTP level tells
those apart, and the module deliberately never parses the body (it stays an
opaque blob), so only the application can say.
cache_turbo_require_header inverts the default on a location — from cacheable
unless something vetoes it to uncacheable unless the origin affirms it:
location /graphql {
cache_turbo main;
cache_turbo_require_header X-GraphQL-Cacheable; # a header NAME
proxy_pass http://app;
}
The response is stored only if it carries that header with an affirmative
value — yes, 1, or on, case-insensitive, matched whole. Everything else
refuses the store: header absent, no, a value like note, or the same header
sent twice with conflicting values (ambiguous, so the only safe reading is
"don't"). It fails closed by construction — any doubt is a cache miss,
never a wrong serve. (An empty value reads as absent: nginx does not forward
an empty-valued header from the upstream, so it never reaches the gate — either
way, no store.)
Two details worth knowing:
- The argument is a plain header name, not a
$variable. The value is read from the response; a variable here would be evaluated against the request and quietly gate on the wrong thing, so the name is validated at config time. - The header is stripped before storing (like
Ageand the RFC 9213 targeted directives): this cache is its intended consumer, and a hit must not replay the origin's internal signal downstream.
Unset — the default, and every location that doesn't name it — leaves the normal rules above completely untouched.
Conditional requests (304 Not Modified)
If the origin gave the cached 200 an ETag or Last-Modified, the module
answers conditional requests straight from cache — no body, no origin round
trip. A GET/HEAD carrying If-None-Match (matched with the weak comparator,
* matches any cached entry) or If-Modified-Since gets a 304 Not Modified
when the client's copy is still current; If-None-Match wins when both are
present (RFC 7232). Anything else serves the full cached body. This is automatic
— there is no directive to set.
A 304 is only answered from a fresh entry. A stale entry (being served
while a refresh runs) has not been revalidated against the origin, so it serves
the full body rather than asserting "still current" with a 304 (RFC 9111).
Auto-Vary (read the response Vary)
On by default (cache_turbo_auto_vary off; reverts to the old Vary-blind
behavior described below). The module reads the response's own Vary header
and splits the cache by the named request header automatically — no need
to pre-declare the axes. It honours a safe whitelist:
Accept-Encoding (bucketed br/gzip/identity/zstd — but only for a response the
module actually stores encoded; the identity body it normally captures makes
this axis a no-op, see the note below), User-Agent (mobile/desktop
class), Accept-Language (primary-subtag class) and Origin (raw value,
unfolded: it's a CORS security boundary, collapsing distinct origins into one
class would let one origin's response serve another's CORS headers). A response
with Vary: *, or one that varies on Cookie or Authorization, is treated as
uncacheable (those vary per-user — caching them would poison or leak across
users). Any other named header is also treated as uncacheable — the module
can't key on it, and caching a single representation for every value of that
header would serve the wrong one (RFC 9110 §12.5.5).
Accept-Language is folded to its primary subtag only, lowercased: the
first language-range of the header, cut at the first - (and any ;q=
parameter dropped), capped at 8 bytes. en-US,en;q=0.9 and en-GB,en;q=0.8
both fold to en and share a cache entry — the raw header would otherwise
spawn a distinct variant per browser locale string, blowing up the keyspace on
an i18n site with no benefit (nobody serves different bytes for en-US vs.
en-GB). An absent, empty, or malformed header folds to its own empty class
"" rather than being skipped — skipping would collide the axis with a
genuinely present-but-empty header. Accepted cost: pt-BR/pt-PT and
zh-Hans/zh-Hant now share an entry; a site that truly serves different
content per region/script should use an explicit cache_turbo_vary for that
axis rather than relying on auto-Vary.
Don't double-partition the same axis. If you both turn on
cache_turbo_auto_varyand add the matchingcache_turbo_normalize_varybucket (e.g.encoding) for an axis the origin already lists inVary, the cache splits on it twice — once via the normalized-args key, once via the variant key — multiplying the slot count for no benefit. Pick one mechanism per axis:normalize_varywhen you know the axis up front,auto_varyto learn it from the response.
Keying is two-level: the L1 marker copy is node-local, while a configured L2
marker mirror/recovery path is shared. The first time a URL's response is seen to
vary, the module records a tiny vary marker in L1 and stores the body under a
secondary variant key; later requests read the marker and resolve straight to
their variant. The base slot stays empty for varied URLs, so a node that hasn't
learned the Vary yet simply misses to origin — it never serves the wrong
variant. With Redis or memcached configured, an L1 marker miss or stale marker
can consult the shared L2 mirror before going to origin, allowing a cold node
to recover an already-known variant. Without L2, the cold-node miss is
intentional and safe. On by default.
Vary: Accept-Encodingis collapsed automatically. The module captures the identity (uncompressed) body — its body filter runs above gzip/zstd/brotli, which then re-encode per client on every MISS and HIT (theproxy_cachemodel). So one stored copy already serves every encoding correctly, andauto_varyno longer partitions onAccept-Encodingwhen the response it captured is unencoded:gzip,br,zstdand no header at all all resolve to the same slot instead of stacking up to four byte-identical copies of the same URL. (An origin that pre-compresses its own response is refused by default — see What it will and won't cache. The explicitcache_turbo_key_encoded_origin onopt-in stores supported encoding classes separately and retains the serve-sideAccept-Encodingguard;auto_varyremains the safety net for response-driven variation.)
Don't turn
cache_turbo_auto_vary offon a varied origin. It is on by default precisely because, with it off, the cache keys on the request, not on the response'sVary, and there is no safety net: aVary:-carrying response is stored under a Vary-blind key and the first variant stored is served to every client (gzip-vs-brotli, mobile-vs-desktop, language, …) — a cache-poisoning / wrong-representation hazard (RFC 9110 §12.5.5), and forVary: Cookie/Authorizationa privacy leak across users on the stale-serve path. Leave auto-Vary on unless you fold the axis into the key yourself withcache_turbo_normalize_vary(below) or an explicitcache_turbo_key. (There is nocache_turbo_vary_saferefuse-to-store knob —auto_varyis the supported mechanism.)
cache_turbo_vary_ignore — opt out of one axis's refusal
Off by default (empty list). Vary: Accept is very common on APIs and
image CDNs, X-Requested-With still shows up, and Sec-CH-* client hints are
an increasingly default origin behavior — none of them are on the safe
whitelist above, so with plain auto_vary any one of them makes the whole
response permanently uncacheable. cache_turbo_vary_ignore names the header(s)
to drop from a response's Vary line before that whitelist/unknown-axis
check runs, so the module behaves exactly as if the origin had never listed
them:
location / {
cache_turbo main;
cache_turbo_auto_vary on;
cache_turbo_vary_ignore Accept Sec-CH-UA Save-Data;
proxy_pass http://backend;
}
Matching is case-insensitive against the tokens in the response Vary header
(HTTP header names are case-insensitive and the origin's casing is not under
your control).
⚠ This is a cache-correctness decision, not a free win. Naming a header here means you are telling the module that clients differing only on that axis may share one cached body — even though the origin's own
Varyline said they should get different bodies. Only ignore an axis you have verified the origin does not actually vary the body on for your traffic (a common case: an origin that echoesVary: Acceptfor content-negotiation bookkeeping but always returns the same JSON shape). Ignoring an axis that genuinely selects a different body will serve the wrong representation to some clients — the same failure modeauto_vary's refusal exists to prevent (RFC 9110 §12.5.5).
cache_turbo_vary_ignorecan never be used to ignore*,CookieorAuthorization— those three keep their dedicated privacy/security veto regardless of this list, and the directive is rejected at config-load time if you name one. It also never widens the built-in safe-axis whitelist: an ignored token is dropped, not promoted to a keyed axis, so it still never contributes to the variant key even implicitly.
CMS backends (cache_turbo_backend)
A page cache in front of a CMS has one classic footgun: cache a logged-in page,
the admin dashboard, or a cart, and serve it to the world. cache_turbo_backend
is the built-in guard — name your CMS and the module auto-skips the dynamic
surfaces (login/session traffic, admin URIs, search/preview) straight to the
origin, never capturing them, so only anonymous, shareable pages land in the
cache.
cache_turbo ct;
cache_turbo_backend wordpress; # one or more: wordpress woocommerce joomla
# xenforo discourse phpbb drupal mediawiki
# magento ghost
Every preset is opt-in — name the backends you actually run. They stack,
and spaces and | are interchangeable separators:
cache_turbo_backend wordpress woocommerce; # the same thing,
cache_turbo_backend wordpress|woocommerce; # spelled three ways
cache_turbo_backend wordpress | woocommerce;
cache_turbo_backend none; means no preset here. Its job is to switch off a
preset inherited from the server block for one location — without it, a
server-level cache_turbo_backend wordpress; applies everywhere below it and
there is no way to opt a single location out.
There is no
generic/autounion, and both spellings are a config error. They used to meanwordpress+woocommerce+joomla. That was never a safe default:
- it never covered every backend — it named 3 of the 34 presets that now exist, so
autoon a Drupal site silently enabled no Drupal rules;- its
woocommerceshipped without implyingwordpress, leaving/wp-admin/cacheable (see woocommerce.md);- the
joomlain it shipped no cookie rule at all, soautoon a Joomla site looked like it protected logged-in users and did not.A default that is only correct if you already know which parts of it are wrong is a footgun with a friendly name. nginx now refuses to start and names the replacement — rather than accepting the word and enabling nothing, which on an existing WordPress config would quietly start caching
/wp-admin/.
cache_turbo_serve_authorized — let credentialed requests read public entries
Off by default. By default a request carrying an Authorization header is
declined before the cache is even consulted, so a credentialed client can never
read a cached entry — not even the anonymously stored, explicitly public copy
of the very same URL. On an API that authenticates every call to a shareable
public endpoint (GET /v1/catalog with Authorization: Bearer …), that makes
the hit ratio exactly zero.
cache_turbo_serve_authorized on; lifts the lookup refusal only:
location /v1/catalog/ {
cache_turbo main;
cache_turbo_valid 30s;
cache_turbo_serve_authorized on;
proxy_pass http://api;
}
What stays enforced. Two independent guarantees remain, and both are required before a credentialed request is served anything:
- Nothing stored under credentials, ever. The store floor is untouched and
ungated by any directive: a response produced for a request that carried
Authorizationis never captured, so no principal's private response is in the cache to leak in the first place. Turning this directive on cannot change what gets stored — only who may read what was already stored anonymously. - RFC 9111 §3.5 reuse authorisation. §3.5 permits reusing a stored
response for an authenticated request only when the response explicitly
allows it. The entry must have been stored carrying
Cache-Control: public,s-maxage=…, ormust-revalidate/proxy-revalidate; anything else is refused to a credentialed requester and falls through to the origin exactly as on a miss. Entries stored before this feature existed carry no such mark and are refused, so enabling the directive never retroactively exposes an older entry.
⚠️ This widens who may read a cached body. It is safe only if the endpoint's
publicrepresentation really is identical for every principal. If the origin returns per-user data on a URL while still marking itpublic, that is an origin bug — and this directive will faithfully share it. Leave it off unless you have checked the endpoint, and prefer scoping it to the specificlocationthat serves shareable content rather than a whole server.
Nothing about the Vary machinery changes: a response carrying
Vary: Authorization still makes the entry uncacheable, and
cache_turbo_vary_ignore still refuses to ignore that axis.
cache_turbo_store_head — stop HEAD-only URLs being a permanent 100% miss
Off by default. A HEAD may read the cache, but it can never populate
it: nginx sets header_only on a HEAD once headers are sent, so the response
body chain — where this module's store lives — never runs. A URL that only ever
receives HEAD is therefore a permanent 100% miss: every uptime monitor,
link checker and HEAD-issuing crawler pays a full origin round trip, forever,
and the origin never gets to amortise them.
cache_turbo_store_head on; makes a HEAD miss fire one internal background
subrequest — a real GET — so the URL gets an entry:
location /status/ {
cache_turbo main;
cache_turbo_valid 30s;
cache_turbo_store_head on;
proxy_pass http://backend;
}
The subrequest goes through the ordinary capture gate and the ordinary store
path; this feature adds no second way for an entry to be created. It is also
subject to the same zone-wide cache_turbo_background_update_max cap as the
stale-while-revalidate background refresh, so HEAD traffic against many cold
URLs cannot fan out without bound.
The entry is HEAD-only, and that is enforced. A cached entry produced this
way is marked at store time and refused to every non-HEAD request at the
same chokepoint that enforces the breaker-only, encoding-class and RFC 9111
§3.5 marks. A GET against such a URL falls through to the origin exactly as
on a miss, and the response it fetches then replaces the HEAD-derived entry
with an ordinary one — so the guard costs at most one extra fetch over a URL's
lifetime rather than pinning it into permanent misses.
Cost. One wasted origin body per HEAD-miss. That is only worth paying on a workload that really is HEAD-dominated, which is why the directive is off by default: on ordinary traffic the GET that follows would have populated the entry anyway, and the extra fetch buys nothing.
What each preset skips
A request is sent to the origin uncached if it matches any of three checks
for an active preset: a URI prefix, the presence of a query arg, or a
substring of the whole Cookie header — a raw, undelimited search across
cookie names and values, not a lookup of a cookie name (the login/session
cookies carry per-session suffixes, so it matches as a substring). Because the
search is not anchored to a name, a cookie value can trip the rule as
readily as a name: a rule's literal must be distinctive enough that it cannot
plausibly appear as an arbitrary value.
The preset classifier inspects at most 8 KiB of Cookie value bytes in total
across all Cookie header fields. That matches nginx's default 8 KiB ceiling
for one large request-header field while preventing repeated Cookie fields from
multiplying classifier work. If nginx is configured to accept more, an
over-budget request is conservatively sent to the origin without lookup or
storage; the trade is a lost cache hit, never a cacheable private request.
| Preset | URI prefixes | Query args | Cookie header substrings |
|---|---|---|---|
wordpress | /wp-admin/, /wp-login.php, /wp-cron.php, /xmlrpc.php, /wp-json/ | preview, rest_route | wordpress_logged_in_, wp-postpass_, comment_author_ |
woocommerce | /cart, /checkout, /my-account | wc-ajax | woocommerce_items_in_cart, woocommerce_cart_hash, wp_woocommerce_session_ |
joomla | /administrator/ | — | joomla_remember_me_ ‡ |
xenforo † ¤ ✦ | /admin.php, /install/, /api/, /login, /logout, /lost-password, /register, /account, /conversations, /direct-messages, /misc | _xfToken | xf_session, xf_user, xf_session_admin, xf_lscxf_logged_in; (key) xf_style_id, xf_style_variation, xf_language_id |
discourse † | /admin, /session, /auth/, /login, /logout, /signup, /my/, /message-bus/, /drafts, /presence/, /notifications, /user_actions | api_key, api_username | _t= |
phpbb † | /ucp.php, /mcp.php, /adm/, /posting.php, /memberlist.php, /search.php, /report.php | sid | (value) …_u != 1 ∆ |
drupal † | /user, /admin, /node/add, /system/, /core/install.php, /jsonapi, /oauth | — | SESS ¥ |
mediawiki † | — ¶ | veaction, returnto, mutating action= values ‡ | Token=, _session=, UserID= |
magento † ✦ | /checkout, /customer, /graphql, /rest, /soap, /sales, /newsletter, /wishlist, /paypal, /review, /page_cache/block/esi, /health_check.php | — | (key) X-Magento-Vary ✦ |
shopware6 † ✦ | /account, /checkout, /admin, /api, /store-api | — | (key) sw-cache-hash ✦ |
ghost † | /ghost/, /members/, /p/, /r/ | uuid, key, token, gift | ghost-members-ssr, ghost-admin-api-session |
wagtail † § | /admin/, /django-admin/, /documents/ | — | sessionid |
kirby † § | /panel | — | kirby_session |
typo3 † ※ | /typo3 | — | fe_typo_user, be_typo_user |
invision † | /admin, /login, /register, /lostpassword, /messenger | do=compose, do=post, do=reply, do=report, module=messaging | _loggedIn (suffix); (key) ips4_hasJS, ips4_theme, ips4_language |
smf † | — | action=admin, action=login, action=login2, action=logintfa, action=logout, action=profile, action=pm, action=post, action=post2, action=moderate, action=reporttm, action=xmlhttp | SMFCookie (presence-only) |
vanilla † | /dashboard, /entry/, /messages/, /post/ ⁂ | — | Vanilla= (presence-only) |
punbb † | /admin.php, /admin/, /login.php, /post.php, /message_send.php, /message_delete.php, /misc.php | — | forum_cookie, punbb_cookie (presence-only) |
phorum † | admin.php, login.php, register.php, pm.php, posting.php, post.php, moderation.php, control.php, ajax.php, report.php, follow.php | — | phorum_session_v5, phorum_session_st, phorum_admin_session_v5; (key) list_style |
yabb † | — | action=post, action=post2, action=login, action=login2, action=register, action=register2, action=admin, action=pm, action=imsend, action=imsend2 | Y2User-, Y2Pass-, Y2Sess- (prefix) |
mybb † | /member.php, /usercp.php, /private.php, /modcp.php, /newthread.php, /newreply.php, /editpost.php, /polls.php, /admin/, /xmlhttp.php | action=login, action=do_login, action=logout, action=do_logout, action=register, action=do_register, action=activate, action=lostpw, action=do_lostpw, action=resetpassword | user (suffix, presence); (key) mybbtheme, mybblang |
vbulletin † | /login.php, /register.php, /usercp.php, /private.php, /profile.php, /cron.php, /admincp/ | — | userid, password (suffix, non-empty), imloggedin == yes; (key) bb_language |
textpattern † | /textpattern | — | txp_login_public, txp_login |
bludit † | /admin, /install.php | — | BLUDIT-KEY (also __Secure-), BLUDITREMEMBERUSERNAME, BLUDITREMEMBERTOKEN |
spip † | /ecrire | action, var_mode | _session, _admin, _lang, _lang_ecrire, _accepte_ajax (suffix, non-empty) |
bugzilla † | account/login, mutation, admin/edit and API CGI entry points | Bugzilla_api_key, api_key, Bugzilla_api_token, Bugzilla_token, Bugzilla_login, Bugzilla_password, Bugzilla_login_token, token | Bugzilla_login, Bugzilla_logincookie |
mantisbt † | login/signup/account/form, /admin/, /api/ entry points | — | _STRING_COOKIE, _PROJECT_COOKIE, _VIEW_ALL_COOKIE, _BUG_LIST_COOKIE, _collapse_settings (suffix, non-empty); PHPSESSID |
plone † | /login, /logout, /register, /passwordreset, /mail_password, /manage, /@@login | — | __ac, _ZopeId, statusmessages, I18N_LANGUAGE |
umbraco † | /umbraco | — | UMB_UCONTEXT, back-office token/preview/XSRF cookies, UMB_SESSION, .AspNetCore.Identity.Application |
dotclear † | /admin, /preview, /pagespreview | — | dcxd, dc_admin, dc_passwd |
wikijs † | admin/editor/history/source/upload, login/identity and GraphQL routes | — | jwt, connect.sid, loginRedirect |
redmine † | /admin, /my, /login, /logout, /account, /settings, /enumerations, /roles, /trackers, /custom_fields, /auth_sources, /mail_handler | key | _redmine_session, autologin |
flarum † | /admin, /api, /login, /logout, /global-logout, /register, /reset, /confirm, /settings, /notifications | — | flarum_remember only — flarum_session is guest-issued and deliberately unmatched |
opencart † | — (all routing is /index.php?route=) | route=account/… and route=checkout/… (enumerated), user_token, customer_token | — (none: OCSESSID is guest-issued, login state is server-side only) |
classicpress is an alias for wordpress, and backdrop is an alias for
drupal; the upstream forks retain their base project's cookie and route
contract, so aliases reuse the audited rule set instead of consuming duplicate
preset bits. mantis is accepted as a convenience alias for mantisbt.
† Opt-in, like every preset. These backends' dynamic surfaces are generic
English paths (/login, /register, /user, /admin, /session) that an
unrelated site may legitimately serve as perfectly cacheable pages. Enabling one
you do not run punches holes in your own cache — which is why none of them is
ever enabled implicitly, and why the old generic union is gone. Name them:
cache_turbo_backend xenforo;.
⁂ Vanilla ships no /api row, and that is a known gap rather than a
decision: Vanilla's API v2 is Bearer-authenticated, so it is the same
header-auth class as magento /rest and drupal /jsonapi. It is absent
because github.com/vanilla/vanilla now 404s and the prefix cannot be
verified against any surviving upstream tree. Add cache_turbo_bypass_uri /api; if you serve it.
‡ edit, submit, delete, protect, unprotect, purge, rollback,
revert, watch, unwatch, markpatrolled, mcrundo, mcrrestore — the
mutating half of ActionFactory::CORE_ACTIONS. The read half (view,
history, raw, render, info, credits) stays cacheable.
¶ mediawiki deliberately ships NO URI rule, and that is the correct shape —
it is what upstream does. It used to bypass /index.php, /load.php and
/api.php; all three were wrong. On a stock wiki $wgArticlePath is
/index.php?title=Foo, so /index.php is the article read path — that rule
bypassed essentially every article read. /load.php (ResourceLoader) and
/api.php are among the hottest cacheable objects on a wiki; Wikimedia's
production VCL explicitly ring-fences them, by ticket number (T102898, T113007),
against a rule that would have made them private. Their frontend has no
path-based pass rule at all. The cookie rules plus the Cache-Control floor are
the whole mechanism. Do not re-add a path rule here without a source that says
MediaWiki cannot cache it.
∆ A cookie VALUE predicate, not a name match. Most presets classify on cookie
name presence. That is useless for an app that issues the same cookie to
guests and members and puts the distinction in the value — a presence rule
there matches everyone and identifies nobody. phpBB is the case: every non-bot
visitor gets <cookie_name>_u, holding 1 (ANONYMOUS) for a guest and the
real user_id for a member (session.php,
constants.php).
So the preset tests …_u != 1.
The cookie name is matched by suffix, deliberately: the prefix is
config('cookie_name') (default phpbb, so the wire name is phpbb_u), an ACP
setting that installers randomise and that any admin running two boards on one
domain changes. A literal-name rule silently stops firing on such a board —
and a bypass rule that stops firing caches the member's page and serves it to
strangers. Suffix matching is prefix-agnostic; it can over-match an unrelated
cookie ending in _u, which costs a needless bypass and never leaks.
An unreadable cookie (no =, malformed) fails closed to bypass: a false
bypass costs one cache miss, a false hit costs somebody else's session.
‡ The cookie rule is a PARTIAL guard — you must still add your own. One preset cannot fully identify a logged-in user:
| Preset | What it can and cannot see | What you must do |
|---|---|---|
joomla | joomla_remember_me_ is a real fixed prefix and is auth-only — but it exists only for users who ticked "Remember Me". A normally-logged-in frontend user carries only the session cookie, whose name is md5($secret . $session_name) — a per-install hash with no stable substring. That user is invisible to the matcher. | Add your own cache_turbo_bypass and cache_turbo_no_store on your install's session-cookie name if your site has frontend logins (guide) — the bypass skips only the lookup, so on its own the logged-in page is still stored. Do not read the cookie rule as "handled". |
phpbb | Handled by a cookie VALUE predicate (∆). _u/_k/_sid are set for guests too (an anonymous visitor gets _u=1 — ANONYMOUS), so presence identifies nobody; a logged-in member carries _u=<user_id>, never 1. The preset now tests the value, matching the cookie name by suffix because the prefix is config('cookie_name') (default phpbb, often renamed). | Nothing extra. A stock cache_turbo_backend phpbb; now bypasses logged-in members. (Before this, it did not — see guide.) |
¤ xenforo cannot be made both safe and fast on stock XenForo. Stock XF2 has
no login-only cookie. xf_user is the remember-me cookie — completeLogin()
only mints it inside if ($remember), so any member who does not tick "Stay logged
in" carries only xf_session. The preset therefore bypasses on xf_session as
well, which is the only correct cookie-only option, and it costs hit rate: XF's
session is lazy, so a clean first-time guest still caches, but a guest who logs
out, trips 2FA, or hits a captcha acquires a session and is uncacheable from then
on. If you run LiteSpeed's XF2 plugin you get xf_lscxf_logged_in, a true
login-only cookie (the plugin exists to create the cookie XF lacks) — that is the
fast path, and you can then drop xf_session with your own config. See
docs/xenforo.md.
¥ drupal's SESS rule over-matches, deliberately. SESS is a substring of
PHPSESSID and JSESSIONID, so a co-hosted PHP or Java app under the same server
block also bypasses. That is a hit-rate loss on the other app, never a leak —
and it is the accepted price of closing a real leak on the Drupal side: Drupal
opens a session for anonymous users whenever anything writes to $_SESSION
(a status message after a form submit, cart contents), so a logged-in user's
SESS<hash> cookie must be excluded or their page can be stored and served on.
Narrow it with your own cache_turbo_bypass $cookie_SESS<your-hash> — paired
with a cache_turbo_no_store on the same cookie, since a bypass only skips the
lookup and still stores — if the collision costs you. Note NO_CACHE is not matched: it is contrib (not core) and is set
for logged-out visitors by design, so it would cost hits and buy no safety.
✦ A cookie VALUE folded into the cache KEY, not a bypass. magento is the one
preset that neither bypasses nor merely tests a cookie's value as a pass/fail
predicate (∆, above) — it puts the cookie's value into the cache key itself,
via a distinct key_cookies tier. X-Magento-Vary is Magento's
Context::getVaryString() — a salted hash of the sorted tuple
{customer_group, customer_logged_in, store, currency}, present only when a
field differs from its default (Framework/App/Http/Context.php). Magento's own
Varnish VCL hashes this value into the cache key (vcl_hash) and never passes on
it; the built-in PHP Full Page Cache folds the same value into its cache id
(Framework/App/PageCache/Identifier.php). Two independent upstream
implementations agree it is a key component, not a gate.
Bypassing on it — what this preset did before — was wrong: a plain anonymous visitor never gets the cookie at all, so bypass caught only non-default contexts (a EUR guest, a switched store view) that hold zero private data, for no safety benefit (the cart is fetched client-side, never in the cached HTML). Presence-keying would be a real leak (it collapses every non-default context — customer A, customer B, a EUR guest — into one shared bucket). Value-keying is neither: each vary context gets its own entry, exactly like upstream.
Key-cookie name matching is exact, not by suffix like ∆ — the value goes
straight into the cache key, so a loose match would let an attacker pick their
own bucket with a cookie like NOT-X-Magento-Vary. The module parses the raw
Cookie: header itself (scanning every Cookie: header a client sends, not
just the first) because nginx's $cookie_ variables cannot represent a
hyphenated name ($cookie_X_Magento_Vary silently never matches — no -→_
translation for cookie names, unlike $http_*). See magento.md.
shopware6 uses the same tier on sw-cache-hash, and xenforo uses it on the
presentation cookies xf_style_id / xf_style_variation (XF 2.3 light/dark) /
xf_language_id — the visitor's chosen style, dark-mode variation and language
each get their own shared cache entry rather than being dropped from the cache.
Unlike Magento's identity-derived vary string these are pure preference values,
so there is no private data at stake; the point is purely to stop a dark-theme or
second-language visitor from missing the cache. See xenforo.md.
§ Cookie rule is conditional — and fails safe. Both of these ride a cookie the app issues only once a session actually exists, which is exactly what makes them shippable. That property is the application's to break:
| Preset | The cookie stops meaning "logged in" when… | Consequence |
|---|---|---|
wagtail | the Django app writes the session for guests — an anonymous cart, a large guest flash message (contrib.messages overflows to the session), or CSRF_USE_SESSIONS=True | every guest gets sessionid → bypassed → hit rate 0 |
kirby | a template calls csrf() (any contact/search/comment form) | guests on that page get kirby_session → that page stops caching |
In both cases the failure direction is a needless bypass — lost hits, never a
leak. That asymmetry is the entire reason they ship while flarum does not: an
ordinary Flarum login (remember-me unticked, the default) carries only the
guest-issued flarum_session, so the only available cookie rule would serve a
cached anonymous page to a logged-in user. Verify with
curl -sI https://site/ | grep -i set-cookie — a logged-out request must set no
cookie — and re-check after deploys. wagtail ·
kirby · why not Django/Laravel themselves.
※ typo3 is the same lazy-session shape — but the only preset here that
fails UNSAFE, not safe. FrontendUserAuthentication sets $dontSetCookie = true by default (FrontendUserAuthentication.php:155), so an anonymous
frontend visitor gets no fe_typo_user — good hit rate, same mechanism as
wagtail/kirby. The difference: the cookie name is read from
$GLOBALS['TYPO3_CONF_VARS']['FE']['cookieName']
(FrontendUserAuthentication::getCookieName(), :167), an admin-overridable
default, not a per-install hash. If a site sets it, the preset's fe_typo_user
substring silently stops matching — and because this is a bypass rule, a
lost match means a logged-in page gets cached and served to strangers, the
opposite of the wagtail/kirby failure direction. If you override
FE/cookieName, add cache_turbo_bypass $cookie_<your_name>; and
cache_turbo_no_store $cookie_<your_name>; yourself — the bypass alone skips
only the lookup and still stores the logged-in response. The
preset also matches be_typo_user (the backend session) independently — it
catches an editor previewing the frontend, who carries no fe_typo_user at
all. See typo3.md.
So a WordPress admin (wordpress_logged_in_… cookie), a ?preview=true draft, a
/wp-json/ API call, a WooCommerce cart cookie, a /checkout page, a logged-in
XenForo member (xf_user), a Discourse user (_t) or a MediaWiki editor
(…UserID) all bypass the cache automatically — no hand-written
cache_turbo_bypass/no_store rules.
Per-application guides, each with a copy-paste vhost (page cache + Redis L2), the cookie/key decisions and the application-specific footguns: WordPress · WooCommerce · Joomla · XenForo · Discourse · phpBB · Drupal · MediaWiki · Magento · Shopware 6 · Ghost · Wagtail · Kirby · TYPO3 · Textpattern · Bludit · SPIP · Bugzilla · MantisBT · Plone · Umbraco · Dotclear · Wiki.js · Redmine · Flarum · OpenCart — index at
docs/. Running a framework rather than one of these apps (Django, Laravel, Rails)? frameworks.md explains why there is no preset for it and how to derive your own rule.PrestaShop, NodeBB, Grav or Craft CMS? Those have guides too — prestashop.md · nodebb.md · grav.md · craft.md — but no preset keyword, because each one either hands its session cookie to anonymous guests (so bypassing on it disables the cache rather than protecting anyone) or derives its identity cookie name from an install-specific hash. Each page ships a hand-rolled vhost instead.
Caveats you should not skip:
joomlahas only a remember-me cookie rule and does not protect an ordinary frontend login until you add your deployed session cookie as a bypass plus matchingcache_turbo_no_store. phpBB's built-in_u != 1value predicate does protect members even though all visitors receive the cookie. A bypass alone skips only lookup and still stores.woocommerceimplieswordpressautomatically (so/wp-admin/is covered without stacking);drupal+mediawikilean on the origin's ownCache-Control: private, so don't setcache_turbo_cache_control ignoreon those;woocommerce's/cart,/checkout,/my-accountprefixes are English defaults that match nothing on a translated store (WooCommerce creates the pages in the site locale), so add your owncache_turbo_bypass_urithere and rely on the stackedwordpresscookie rules; andtypo3's cookie name is admin-overridable (FE/cookieName) — if you change it, add your owncache_turbo_bypassandcache_turbo_no_store, or logged-in pages get cached (this one fails unsafe, not safe — see typo3.md).
"Session" in a cookie name does not mean "logged in"
The single most common way to wreck a cache with these presets. XenForo's
xf_session, Discourse's _forum_session, phpBB's _sid and MediaWiki's
<prefix>_session are all handed to anonymous visitors. Bypassing on one
drops most of your traffic out of the cache for zero safety gain — a performance
bug wearing the costume of a safety measure. None of them is in the table above,
on purpose.
Check whether a cookie is set for a logged-out visitor before you bypass on
it. curl -sI your site with no cookies and look at what comes back in
Set-Cookie.
XenForo: which cookies bypass, and which belong in the key
Stock XenForo has no login-only cookie (xf_user is only the remember-me
cookie), so the ordinary member carries only xf_session. That forces xf_session
into the bypass list even though guests get it too — the full reasoning is in
docs/xenforo.md.
The preset:
| Cookie / arg | Treatment | Why |
|---|---|---|
xf_user, xf_session_admin, xf_session | bypass | the three identity signals. xf_session is guest-issued but is the only cookie a non-remember-me login carries — omitting it is a real cross-user leak. |
xf_lscxf_logged_in | bypass | LiteSpeed addon's true login-only cookie (present only if you run it). |
_xfToken (query arg) | bypass | XF's per-session CSRF token on some GET links (logout, style switcher). |
xf_style_id, xf_style_variation, xf_language_id | value-keyed | presentation variants — style, XF 2.3 light/dark, language. Folded into the cache key by the preset, so each value gets its own shared entry. |
xf_consent | ignored | changes embed HTML but fragments the cache heavily; key it yourself only if you need it. |
The style/language cookies are value-keyed by the preset itself now (tier-3
key-cookies, same engine as magento/shopware6) — you do not add them to
cache_turbo_key by hand. So do not do this:
# WRONG — a per-session key gives every visitor their own private copy.
# Hit rate collapses to ~0, and logged-in pages still get *stored*.
cache_turbo_key $host$uri$cookie_xf_session$cache_turbo_normalized_args;
Keying on the session cookie does not make a logged-in page safely shareable — it mints one entry per visitor (nothing is ever reused) while still storing authenticated HTML. Bypassing sends it to the origin and never captures it. Key on the variant, bypass the identity — and the preset does both for you:
cache_turbo_backend xenforo; # bypasses identity, value-keys style/language
cache_turbo_key $host$uri$cache_turbo_normalized_args; # plain key is enough
This is the same split LiteSpeed's own XenForo plugin makes (bypass on
xf_lscxf_logged_in/xf_user/xf_session_admin, vary on xf_style_id +
xf_language_id); we add xf_style_variation, the 2.3 dark-mode cookie that
post-dates that addon's rules.
Three caveats worth checking against your install:
- Custom cookie prefix. The names assume XenForo's default
$config['cookie']['prefix'] = 'xf_'. If you changed it, the preset won't match — add your owncache_turbo_bypass $cookie_<prefix>session $cookie_<prefix>session_admin $cookie_<prefix>user;and the same list oncache_turbo_no_store(the bypass skips only the lookup and still stores). All three:sessionis the only cookie an ordinary (non-remember-me) login carries,session_adminthe admin session,userremember-me. Bypassinguseralone leaves ordinary and admin logins cacheable — the leak this preset exists to prevent. - The REST API (
/api/) authenticates on theXF-Api-Keyheader, not a cookie — the preset bypasses it on the URI so one client's private response is never served to another. Bypass any non-standard API path too. - A board in a subdirectory (
/forums/…) shifts every URI prefix above. The preset matches onr->urifrom the site root and its prefixes are anchored at byte 0, so/forums/logindoes not match/loginand the board gets zero URI-rule coverage — the admin surface included. Scoping thelocationdoes not help (it routes requests, it does not rewriter->uri): declare the mount withcache_turbo_backend_prefix /forums/;(see the vhost example indocs/xenforo.md).
Interactions and safety
- Implies
cache_turbo_cache_control honor(unless you set it explicitly). So if your CMS plugin already emitsCache-Control: no-cacheon a page it knows is dynamic, that page self-excludes at store time too — belt and braces. Pin a fixed TTL instead with an explicitcache_turbo_cache_control respect;(e.g. for microcaching — see below). - It is a floor, not the only one. Auto-skip sits under the manual
cache_turbo_bypass/cache_turbo_no_storeoverrides, and the universal safety rules still apply on top of it regardless of preset: a response withSet-Cookie, or a request carryingAuthorization, is never cached. The preset widens the net for CMS-specific surfaces those generic rules miss (an admin URL with no cookie yet, a search query), it doesn't replace them. - Not a security boundary for your own private routes. The presets cover the
well-known CMS surfaces above; a custom
/members/-style area still needs its owncache_turbo_bypass $cookie_yoursession;pluscache_turbo_no_store $cookie_yoursession;— a bypass skips only the lookup, so without theno_storehalf the private page is still stored under the shared key (or a raw default key,cache_turbo_key $scheme$host$request_uri;, for origins that don't reliably mark per-user responsesprivate).
The cache key
A "key" is just the string that decides whether two requests are the same
page. The built-in default key is the Host header plus the raw unparsed
request URI — r->headers_in.server (the validated Host, or matched
server_name) concatenated with r->unparsed_uri (path + raw query string,
with no decoding and no argument normalization). This is not the same
as the nginx expression $host$uri$query_string: $uri is decoded and
normalized (dot-segments collapsed, percent-decoded) while unparsed_uri is
not, so the closer nginx-variable equivalent is $host$request_uri. Two
vhosts sharing a zone never collide (the Host is in the key), but by default
?utm_* tracking params and arg-reordering create separate entries. To
enable normalized matching (params stripped, args sorted), set
cache_turbo_key to $host$uri$cache_turbo_normalized_args explicitly.
Scheme and port are not in the default key. The default key has no
$schemeand no listener port, so an HTTP request and an HTTPS request for the same Host + path map to the same cache entry whenever both server blocks share onecache_turbozone. The Host is still in the key, so this is not cross-tenant poisoning — it is representation-mixing of what the module treats as the same resource served two ways. If the origin's response actually differs by scheme (scheme-absolute URLs in the body, per-scheme HSTS or redirect behavior), a response fetched under one scheme can be served back under the other. Fix by putting$scheme(and, if you multiplex ports within one scheme,$server_port) into the key:cache_turbo_key $scheme$host$request_uri;. Giving the HTTP and HTTPSserverblocks separatecache_turbo_zones is an equally valid fix.
Set your own with cache_turbo_key using any nginx variables:
cache_turbo_key $scheme$host$uri$is_args$args;
Cache-key normalization
$cache_turbo_normalized_args rebuilds the query string so equivalent requests
share one slot: it sorts args (?b=2&a=1 == ?a=1&b=2) and drops tracking
params (built-in denylist: utm_*, fbclid, gclid, msclkid, mc_eid,
_ga, ref, sid, sessionid, tmp_*). Add more with
cache_turbo_normalize_strip, or nuke them all with
cache_turbo_normalize_strip * (a bare * is a zero-length prefix that matches
every arg name).
Alias caveat. Because
$cache_turbo_normalized_argsstripssid/sessionid/refand sorts the rest, two distinct URLs that differ only in a stripped param (e.g. two?sessionid=values) collapse onto one entry when you use the normalized key. That is the point for tracking junk, but it is wrong if the origin actually keys private content off such a param without marking itprivate/Set-Cookie. For those origins use a raw, no-strip/no-sort key so distinct queries never alias:cache_turbo_key $scheme$host$request_uri;(or the default raw key).
cache_turbo_key $host$uri$cache_turbo_normalized_args;
cache_turbo_normalize_strip sid sessionid "tmp_*";
cache_turbo_normalize_vary encoding device; # keep gzip≠brotli, mobile≠desktop
Sort bound (cache_turbo_normalize_max_args)
Normalizing means sorting the surviving params, and that sort is nginx's
ngx_sort() — an insertion sort, O(n²). It runs whenever an explicit
cache_turbo_key uses $cache_turbo_normalized_args, before the cache
lookup, so a hit cannot absorb it. The raw built-in key does no normalization
work. For an opted-in normalized key, the param count is bounded only by the
size of the request line (~8k by default). At -O2 the worst case costs:
| kept params | worker CPU per request |
|---|---|
| 32 | 0.003 ms |
| 64 (default cap) | 0.011 ms |
| 128 | 0.041 ms |
| 1000 | 2.06 ms |
| 4000 | 23 ms |
An unauthenticated client could therefore spend tens of milliseconds of a
worker's single-threaded CPU per request. cache_turbo_normalize_max_args
caps it at 64 kept (post-strip) params by default. Above the cap the
module skips normalization entirely and keys on the raw query string:
the request is still served correctly and two identical requests still land
in the same slot — the query is simply not sorted or stripped, so junk-laden
and reordered variants of an over-cap URL get their own slots.
Raise it if you genuinely serve URLs with hundreds of meaningful params, or
set 0 for unlimited (the pre-cap behaviour, and no bound on the sort):
cache_turbo_normalize_max_args 256; # sort up to 256 params
cache_turbo_normalize_max_args 0; # unlimited -- removes the DoS bound
Presets (pick a vibe, skip the knobs)
Don't want to tune five numbers? Pick a preset:
cache_turbo ct;
cache_turbo_preset aggressive; # long TTLs, wide stale window, eager refresh
| Knob | micro | conservative | balanced (default) | aggressive |
|---|---|---|---|---|
fresh TTL (valid) | 1s | 30s | 60s | 300s |
| `beta$ (\text{refresh} \text{eagerness} \times 1000) | 1000 | 500 | 1000 | 3000 |
| 1\text{s} | 10\text{s} | 5\text{s} | 3\text{s} | |
| \text{stale}-\text{window} \text{multiplier} | \times 2 | \times 2 | \times 4 | \times 8 |
| $min_uses` (misses before storing) | 1 | 1 | 1 | 2 |
Any explicit knob (cache_turbo_valid 120s;) still beats the preset.
aggressive is the only preset that raises min_uses, to 2: a key is
stored on its second cold miss, not its first, so a one-hit-wonder URL never
spends a cache entry on itself. That is the trade an operator asking for maximum
hit-rate on a long-tail site wants — but it costs one extra origin fetch for
every genuinely repeated key, so it is deliberately not the default. If you
want the aggressive TTLs without the admission gate, set cache_turbo_min_uses 1
explicitly; an explicit directive beats the band like any other knob.
The stale window is valid × (multiplier − 1). So balanced + valid 60s
= fresh for 60s, then served stale for another 180s, then expired.
micro is the microcaching preset: a 1-second fresh TTL with a tight ×2
stale window and a 1s single-flight lock, so a hammered dynamic endpoint is
served from RAM for a second while the backend is hit ~once. It's exactly the
microcaching recipe below in
one word — cache_turbo_preset micro; instead of spelling out valid 1s +
lock_ttl 1s. Override the TTL per-location with cache_turbo_valid as usual.
Microcaching (1-second TTL for APIs and PHP-FPM)
Microcaching = a deliberately tiny TTL (≈1s) on otherwise-dynamic endpoints.
The page is "fresh" for only a second, so the data is near-real-time, but during
that second a burst of N requests is served from RAM and the backend is hit
once — and with the single-flight lock on, even the cold miss at the start
of each second collapses to one origin request instead of a stampede. Net effect
on a hammered /api or PHP app: backend load drops from "every request" to
"~one per endpoint per second", content at most ~1–2s stale.
cache-turbo runs in the ACCESS phase and captures the response in a body filter,
so it is upstream-agnostic — the exact same directives microcache a
proxy_pass API and a fastcgi_pass PHP-FPM app. Because only GET is cached,
mutations (POST/PUT/DELETE) always pass straight through.
# A) JSON API behind proxy_pass — 1s microcache
location /api/ {
cache_turbo ct;
cache_turbo_preset micro; # valid 1s + lock_ttl 1s + ×2 stale, in one word
cache_turbo_lock on; # collapse a per-second burst to ONE origin hit
cache_turbo_lock_timeout 1s;
cache_turbo_min_uses 2; # don't cache one-shot endpoints (optional)
# never serve a cached body to an authenticated caller (Authorization is
# already refused both ways; this also covers cookie sessions)
cache_turbo_bypass $http_authorization $cookie_session;
cache_turbo_no_store $http_authorization $cookie_session;
proxy_pass http://api_upstream;
}
# B) PHP-FPM (WordPress/Laravel/…) — 1s microcache
location ~ \.php$ {
cache_turbo ct;
cache_turbo_preset micro; # valid 1s + lock_ttl 1s + ×2 stale
cache_turbo_lock on;
# WP/Woo: auto-skip wp-admin, login + logged-in cookies. (Implies
# cache_turbo_cache_control honor — see the gotcha below.)
cache_turbo_backend wordpress;
# force the fixed 1s TTL instead of letting the app's Cache-Control win
cache_turbo_cache_control respect;
# belt-and-braces: never store a session response
cache_turbo_no_store $cookie_PHPSESSID;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
Microcaching gotchas:
- The
micropreset keeps the window tight. It isvalid 1s+lock_ttl 1s+ a ×2 stale multiplier, so a copy is served stale for ≤1s more (≤2s old). Usingbalanced/aggressiveinstead widens that (×4/×8 = up to 4s/8s stale) — usually not what you want for "near-real-time". To microcache at a different TTL, keepcache_turbo_preset microand add an explicitcache_turbo_valid 2s;(the explicit knob wins; the ×2 stale window scales with it). cache_controlmode vs a fixed TTL. A CMS preset (cache_turbo_backend) defaultscache_turbo_cache_controlto honor, so an app that emitsCache-Control: max-age=600would override your1s. Setcache_turbo_cache_control respect;to pin the microcache TTL regardless of app headers (example B). For an API that you want to honour its ownCache-Control, leave it onhonorand drop the staticvalid.- Per-user safety. Anything with an
Authorizationrequest header, or a response withSet-Cookie/Cache-Control: private, is never cached or served from cache. For cookie-session apps add an explicitcache_turbo_bypass/cache_turbo_no_storeon the session cookie so a logged-in GET is never collapsed onto the anonymous slot (example A). - Background refresh stays on. With
cache_turbo_background_update on(the default) the once-per-second refresh is non-blocking — clients always get an instant answer and a 5xx during refresh leaves the last good copy in place (stale-if-error). Turn it off only if you need every served body strictly ≤1s old at the cost of one client per second waiting on the backend.
Your origin died
Real-world stacks rarely emit stale-if-error (RFC 5861) — WordPress, PHP, Discourse, Magento typically ship zero grace window for outages. When the origin goes down and a cached page has already expired, cache_turbo_keep_stale lets you configure that grace window instead of waiting for the app to send one:
location / {
cache_turbo ct;
cache_turbo_valid 1h; # fresh for 1 hour
cache_turbo_keep_stale 6h; # if the origin is down, serve stale for up to 6 hours after expiry
proxy_pass http://backend;
}
When the origin is unavailable and an entry has passed its cache_turbo_valid TTL, the response is still served (from cache) for as long as cache_turbo_keep_stale covers the request. After that window closes, the error surfaces normally (a 5xx or timeout).
An origin that sends its own stale-if-error wins outright — cache_turbo_keep_stale is the last resort for the common case where it sends none, not a floor that widens one the origin already scoped. Two interactions worth knowing: cache_turbo_cache_control ignore does not disable cache_turbo_keep_stale (it makes the upstream header inert; this is operator config), while a response must-revalidate does suppress it — that directive forbids serving stale at all. Full precedence in the Directive synopsis entry.
Which failures count as "the origin is down"
By default, "down" means any 5xx. That covers a dead origin, because nginx turns a refused connection into a 502 and a hung one into a 504 before this module ever sees the response. It does not cover a half-broken origin — a bad deploy that 404s every page, an over-eager rate limiter answering 429 — because those are perfectly valid HTTP responses as far as the cache is concerned.
cache_turbo_use_stale picks the trigger set:
cache_turbo_keep_stale 6h;
cache_turbo_use_stale error timeout http_404 http_429;
Tokens: off, error, timeout, http_403, http_404, http_429, http_500,
http_502, http_503, http_504. The default is every 5xx, so leaving the
directive out keeps today's behaviour exactly. off disables serve-on-error
entirely and may only appear on its own.
⚠ Naming tokens replaces the default — it does not extend it. Writing
cache_turbo_use_stale http_500 http_502 http_503 http_504 looks like a spelled-out
version of the default, but it is narrower: the default also covers the 5xx statuses
that have no token of their own (501, 505, 507, 508, 510, 511), and those stop
falling back to a stale copy the moment you name anything. If you want the default
plus one more status, you still have to list the 5xx you care about explicitly, or
leave the directive out and accept every 5xx.
⚠ error and timeout are vocabulary compatibility with
proxy_cache_use_stale, not behavioural parity. In nginx those are
communication-failure classes: error means the connection failed, as distinct
from an upstream that genuinely answered 502. This module decides at the response
header filter, where the only thing it can see is the final status — by then a
refused connection and a real 502 are the same number. So error behaves as
http_502 and timeout behaves as http_504. Write whichever reads better;
they select the same responses.
Circuit breaker + stale-on-error for origin protection
When the origin becomes unreliable (degraded response times, rare failures), you can combine the circuit breaker with cache_turbo_keep_stale to shield downstream clients and give the origin time to recover:
location / {
cache_turbo ct;
cache_turbo_valid 5m; # fresh TTL: 5 minutes
cache_turbo_keep_stale 24h; # if origin is down, serve stale for up to 24 hours
cache_turbo_background_update on; # stale-while-revalidate during normal operation
# Circuit breaker: trip after 3 consecutive 5xx in a 30-second window
cache_turbo_breaker on;
cache_turbo_breaker_threshold 3; # fail count needed to open
cache_turbo_breaker_window 30s; # rolling window for counting failures
cache_turbo_breaker_open 2m; # stay open for 2 minutes, then probe once
proxy_pass http://backend;
}
The breaker trips OPEN after three 5xx responses within 30 seconds. While it is OPEN it does not contact the origin at all — that is the point — and requests are answered from the cached copy, fresh if it is under 5 minutes old and stale if it is older. Once the 2-minute OPEN window elapses, exactly one request is promoted to probe the origin: if that probe succeeds the breaker closes and normal traffic resumes, and if it fails the breaker stays OPEN for another window.
A URI with no cached copy at all has nothing to fall back on, so those requests get a 503 carrying a Retry-After hint that tracks cache_turbo_breaker_open. The same applies once a copy ages past a location's cache_turbo_keep_stale window (24 hours by default): serving indefinitely old data is an explicit choice, not a default.
Circuit breaker isolation: per-zone, not per-upstream
⚠ The breaker is keyed PER ZONE, not per upstream. All locations sharing the same cache_turbo_zone name share one breaker state. This means one failing upstream can trip the breaker for every location in that zone, even locations proxying to unrelated healthy upstreams.
Observable effect: If you have two API services in one zone:
http {
cache_turbo_zone name=ct 256m; # ONE zone, ONE breaker
server {
location /service-a/ {
cache_turbo ct; # ← same zone
cache_turbo_breaker on;
proxy_pass http://service-a.example.com;
}
location /service-b/ {
cache_turbo ct; # ← same zone
cache_turbo_breaker on;
proxy_pass http://service-b.example.com;
}
}
}
If service-a.example.com is down and trips the breaker (5 consecutive 5xx within 10 seconds, by default), then service-b.example.com also gets rejected with a 503 while the breaker is open, even though service-b is healthy. The breaker does not discriminate per-upstream — it only knows "the zone is down, do not call any origin".
This is a deliberate design choice documented in the source (ngx_http_cache_turbo_module.h:266): the zone is already the accounting unit for stats and autotune, and a per-upstream breaker would need new keying logic with no proven operational gain.
Remedy: Give independently failing or latency-sensitive upstreams their own zone:
http {
cache_turbo_zone name=ct_a 128m; # separate failure domains
cache_turbo_zone name=ct_b 128m;
server {
location /service-a/ {
cache_turbo ct_a; # ← separate zone A
cache_turbo_breaker on;
proxy_pass http://service-a.example.com;
}
location /service-b/ {
cache_turbo ct_b; # ← separate zone B
cache_turbo_breaker on;
proxy_pass http://service-b.example.com;
}
}
}
Now each upstream has its own breaker state. If service-a is down, it trips only ct_zone_a's breaker and serves stale to its own clients; service-b remains on its normal path and responds normally.
The downside is memory: each zone reserves the size you give cache_turbo_zone name=… SIZE, regardless of fill. Split zones only when the failure domains actually differ. For a monolithic origin or a tightly coupled service pair that fails together, one shared zone is correct and saves memory.
What outage handling cannot do
Some failure modes are outside what a cache can fix. They are listed here so you can plan around them rather than discover them during an incident.
- The origin dies mid-body, after the response headers are already on the
wire. In production there is no automatic rescue for this at all — see
"The idea in 30 seconds" above for why no reliable in-process signal
distinguishes it from legitimate chunked/EOF framing (only this module's
own
TEST_FAULTS-only test suite can force it deterministically). Once even one byte of that partial body has reached the client, there is no way to retract it and substitute a cached copy regardless — buffering every complete response before sending any of it would fix this at the cost of the exact latency cache-turbo exists to avoid. - Nothing was ever cached for the URL. Serving stale needs something stale
to serve. A cold URL during an outage has nothing, and no directive changes
that — see the
error_pagenote below for making the failure look better. - L1 lives in shared memory, so an nginx restart empties it. A reload preserves it; a restart does not. Configure a Redis or memcached L2 if you need the cache to survive a restart during an outage — but note that Redis down and origin down leaves this module with nothing on either tier.
- Staleness past the
stale-if-errorwindow is by operator consent. That is whycache_turbo_keep_staledefaults to24h, notforever: serving a day-old page is the right answer for most sites during an outage, but you should still saycache_turbo_keep_stale offif serving stale data is ever wrong for a given location (a bank balance, for example) and pick your own window withcache_turbo_keep_stale <time>otherwise.
For the cold-URL case, prefer nginx's own error handling over anything this module could add:
error_page 502 503 504 /maintenance.html;
That keeps one mechanism for "the origin is unreachable and we have nothing", works identically whether or not cache-turbo is in the location, and stays under your control rather than this module's.
Long-tail URLs: reach for cache_turbo_min_uses first
If the cache is large but the hit rate is poor, the usual cause is not the TTL —
it is one-hit-wonder URLs. Crawlers, tracking-parameter permutations, search
queries and deep pagination each produce a key that is requested once and never
again. Every one of them is stored, and each store evicts something that was
being reused. Raising cache_turbo_valid does not help: the problem is what gets
admitted, not how long it stays.
cache_turbo_min_uses 2; # store on the SECOND miss, not the first
A key is only stored once it has cold-missed N times, so a URL nobody asks for
twice never occupies the cache at all. The counter is a lightweight node (no
body), so the tail costs a slot but not memory. 2 is almost always the right
value — it removes the entire never-repeated class, and higher values mostly
delay caching for genuinely popular pages.
cache_turbo_preset aggressive already sets min_uses 2 for you; every other
preset leaves it at 1. Setting the directive explicitly beats whichever band is
in effect, in both directions.
Watch cache_turbo_min_uses_skips_total. It counts requests that went to the
origin because they were below the threshold: a large and growing number is the
long tail being kept out, which is the knob working — compare it against the hit
rate rather than reading it as a problem on its own.
Two interactions worth knowing:
- L2 wins over the gate. A key already in Redis/memcached is served from L2 regardless of the local miss count — it is already proven popular, and a second node should not have to re-learn that.
- It bounds attacker-chosen key cardinality. When a visitor can steer the key
(a search term, a cookie value folded with
cache_turbo_key_cookie), no cap on the value bounds how many distinct keys they can mint.min_usesdoes, because a key minted once is never stored.
Use it with cache_turbo_lock on, not instead of it: min_uses decides what is
worth storing, single-flight decides how many requests reach the origin while a
key is being filled.
Zone sizing under LRU pressure
The cache zone is a single shared-memory heap with a flat LRU (least-recently-used) eviction list. Four kinds of nodes live in this heap and compete for space:
- Entry nodes — actual cached response bodies with metadata (one node + the response body)
- Single-flight stubs — temporary markers during origin fetch (metadata only, no body)
- min_uses counters — track cold-miss count before storing (metadata only, no body; one per unique key if
cache_turbo_min_uses > 1) - L2 negative memos — "the origin didn't have this key" records (metadata only, if
cache_turbo_l2_negative_ttlis set) - Auto-Vary markers — tiny records for two-level vary discovery (described in Auto-Vary)
By default the LRU is one flat queue and eviction does not distinguish between them (see cache_turbo_scan_resistant to split it into two segments). A counter node holds no response body (its data pointer is NULL) but still consumes a full node structure, so on a long-tail workload with cache_turbo_min_uses 2 enabled, each unique URL costs a slot — the counter saves the response body, not the key slot itself. If your zone is undersized relative to unique key cardinality, the counters and memos can starve out your actual cached entries.
Auto-Vary markers are a second key-slot multiplier, and they share the same flat LRU as your objects. Two-level keying stores a tiny marker node under the base URL key plus one full variant node per distinct variant seen (see Auto-Vary). A URL that varies across three encodings therefore holds up to four nodes — the marker plus three variant entries — where a non-varying URL holds one. On a zone under LRU pressure the markers compete for slots on equal terms with your hot object bodies: because eviction is variant-blind, a burst of cold varied URLs can evict a live marker, which forces the next request for that URL to re-discover the vary axis (an origin round-trip) before it can resolve to a variant again. Two consequences for sizing: (1) count each varied URL as 1 + variant_count key slots, not one, in the cardinality term below; (2) if varied URLs dominate and you see marker churn (evictions high while origin traffic is flat), a larger zone protects markers more cheaply than any per-node tuning — the module keeps markers and objects on one heap by design, so the only lever is total zone size. cache_turbo_scan_resistant helps only indirectly: it shields the frequently-read (hot) segment, and a re-read marker is hot, so splitting the LRU keeps established markers out of the crawler-evicted probation segment.
Watch for thrashing:
When evictions spike but your origin traffic hasn't changed, the zone is too small or the hit rate is genuinely collapsing. Check the cache_turbo_evictions_total counter, exposed at your admin endpoint:
$ curl 'localhost/_cache?format=prometheus' | grep evictions
cache_turbo_evictions_total{zone="ct"} 42000
A large and rapidly growing count under a stable workload signals that min_uses counters or young entries are being evicted faster than they can serve. The remedy is to increase the zone size (see sizing guidance below) or lower cache_turbo_min_uses (it is not useful on an undersized zone).
Rough sizing:
A node structure is 184 bytes on a 64-bit build (sizeof(ngx_http_cache_turbo_node_t): rbtree linkage, the 32-byte key, timestamps, flags, LRU linkage, and the two segmented-LRU fields). It is 184 whether or not cache_turbo_scan_resistant is enabled -- the fields exist unconditionally, they are simply never set to a non-zero value when the feature is off. Slab allocation rounds up and adds its own per-chunk overhead, so budget ~256 bytes per key in practice rather than the bare struct size. This is incurred once per unique key, regardless of whether it is a counter, stub, or full entry. Add the response body byte count for each cached entry:
$ \text{Zone} \text{size} ≥ (\text{unique\_keys} \times ~256 \text{bytes}) + (\text{cached\_responses} \times \text{avg\_body\_size}) $
Treat that as an order-of-magnitude starting point, not a formula to size a zone to the megabyte — slab rounding depends on your allocation size distribution, which depends on your body sizes. Measure, then adjust.
Example:
- 10,000 unique keys in your workload
- 2,000 of them cached (the other 8,000 are one-hit-wonders, admin URLs, etc.)
- Average cached body: 50 KB
Minimum zone ≈ (10,000 × 256 bytes) + (2,000 × 50,000 bytes)
≈ 2.5 MB + 100 MB ≈ 103 MB
Set cache_turbo_zone name=ct 256m; (rounding up for slab overhead + margin). Monitor cache_turbo_evictions_total for the first week. If it stays near zero, the zone is sized well. If it grows steadily, increase the zone size.
Note where the key-slot cost falls. In the example above the 8,000 uncached keys cost a node slot whether or not min_uses is set — a cold miss allocates a node either way. What min_uses 2 changes is that those 8,000 keys never get a response body attached, which is the expensive part. So min_uses reduces body storage on a long-tail workload; it does not reduce the key-slot count, and it is not a fix for a zone that is too small to hold your key cardinality.
That is also why it is not a default: on a workload whose unique-key count alone exceeds the zone, the counter nodes are pure overhead. You cannot predict the break-even point without measuring — set min_uses 2, monitor cache_turbo_min_uses_skips_total (cold misses sent to origin without storing) and cache_turbo_evictions_total (thrashing), and adjust zone size upward or min_uses back to 1.
The zone minimum is enforced at 8 × ngx_pagesize (~32 KB on most systems); your cache_turbo_zone size directive must be at least that.
See also: Auto-Vary marker/variant scheme (two-level keying on a single zone).
Request pool sizing for module performance
The module works within nginx's per-request memory pool, allocated via
request_pool_size (nginx default: 4 KB). On a fresh cache HIT, the
module consumes approximately:
- ~590 bytes for response bodies up to 32 KB (header reconstruction + one
ngx_buf_t). - ~96 additional bytes per extra 32 KB output chunk for larger bodies (one
ngx_buf_t+ onengx_chain_tper chunk).
Here's the key: nginx's default request_pool_size 4096 already chains a
second pool block from core per-request overhead alone — verified with a
bare Host + Connection: close header set, which measured 5094 bytes across 2
blocks on a fresh request. The module does not cause this; it inherits it.
Raising request_pool_size to 8 KB makes a typical cached response (core ~5.1
KB + module ~590 B) fit in a single 8 KB block, saving one malloc per request:
server {
# default: request_pool_size 4k;
request_pool_size 8k; # saves one malloc per request
location / {
cache_turbo ct;
cache_turbo_valid 10s;
proxy_pass http://backend;
}
}
For large bodies, separately: the module forces additional blocks only for bodies exceeding 65,536 bytes (a third 32 KB output chunk). Those cost ~96 bytes per extra 32 KB chunk and are unaffected by the 8 KB tuning above — size the pool for your typical response, then budget additional chunks for outliers.
This tuning applies to all requests — cached or not — making it meaningful at scale. The tradeoff is baseline request-memory growth (nginx itself grows 4 KB → 8 KB); measure whether it pays off on your header set and body distribution. Figures above are measured on nginx 1.31.3 at the compiled-in default, and are host- and header-set dependent — yours may differ. For details and full allocation breakdown, see Benchmarking.
Scan-resistant eviction: cache_turbo_scan_resistant
On by default since v-P3-1 (protected_pct=80). With the flat LRU a
crawler that walks N unique URLs evicts your entire hot set: every first store
goes straight to the LRU head, and every eviction takes the tail. The
crawler's one-hit-wonder URLs are the newest thing in the zone, so they push
out the pages real users actually request. scanbench.sh measured this at
0.0% hot-set survival under a crawler scan on the old flat-LRU default vs
82.0% with segmented LRU at the same protected_pct=80 -- so a stock install
on the old default had zero scan resistance against any crawler, sitemap
fetch or scanner. A stock install now gets segmented LRU with no config
needed:
location / {
cache_turbo ct;
proxy_pass http://backend;
# cache_turbo_scan_resistant on protected_pct=60; # tune the cap
# cache_turbo_scan_resistant off; # restore pre-P3-1 flat LRU
}
Upgrading: existing deployments get segmented LRU automatically on
upgrade to v-P3-1+ -- this changes eviction ORDER (never what is served,
cacheable, or freshness) but is a real behaviour change: a previously flat LRU
now protects twice-hit keys and evicts one-hit keys first. If you rely on the
old flat single-queue eviction order, add cache_turbo_scan_resistant off;
explicitly to any location that must keep it.
- A new store enters PROBATION. An unproven key never starts out protected.
- On its second hit an entry is promoted to PROTECTED. One hit is not enough -- a crawler touching each URL once can never promote anything.
- Eviction takes the probation tail first, and only falls through to the protected tail when probation is empty. That ordering is the whole mechanism: a scan can only evict other scan entries.
- Protected is capped at
protected_pctof resident entries (1..99, default 80). On overflow the protected tail is demoted to the probation head, not discarded -- a demoted-but-hot entry gets a second chance ahead of cold probation entries.
Counter nodes never promote. min_uses counters, L2 negative memos and single-flight stubs hold bookkeeping, not content, so a miss storm cannot pin the protected segment with nodes that carry no body.
When it will not help. If your working set fits comfortably in the zone,
nothing is being evicted and this changes nothing but adds a little bookkeeping.
It pays off specifically when unique-key cardinality exceeds the zone and the
access distribution is skewed -- i.e. you have a real hot set worth protecting.
Measure with cache_turbo_evictions_total and your hit rate before and after;
do not enable it on faith.
Turning it on changes only eviction ORDER. It never changes what is served,
what is cacheable, or any freshness decision, and off (or omitting the
directive) is byte-for-byte the pre-existing flat LRU.
Turning it back off on a live zone. A cache zone survives an nginx -s reload, so an on -> off change inherits entries that were already promoted.
Those are demoted back to probation as they are next accessed, and any that
are never accessed again are drained by eviction once probation empties, so the
zone converges on the flat LRU rather than keeping a permanently privileged set.
The convergence is gradual, not instantaneous; restart (rather than reload) if
you need the flat LRU immediately.
Admission control: cache_turbo_zone ... admission=on
Off by default, and unlike cache_turbo_scan_resistant it stays off.
Scan-resistant eviction only changes which resident entry is given up;
admission control changes whether a response is cached at all. A wrongly
refused store is a silent, permanent extra origin hit for that key, so the flip
is an operator decision made per zone after watching the counters.
http {
cache_turbo_zone name=ct 256m admission=on; # opt in, per zone
# cache_turbo_zone name=ct 256m; # default: admission off
}
It is a zone parameter, not a location directive: the decision is made inside the shared-memory store path, which sees the zone and no location config.
What it decides. The module keeps a small W-TinyLFU frequency sketch (a 4-row count-min sketch of 4-bit counters, ~0.05% of the zone) bumped on every access. When a new key would be stored and there is a resident entry on the probation tail, the candidate's estimated frequency is compared against that victim's. If the candidate is strictly colder, the store is refused and the resident entry keeps its slot. Without this, eviction is 100% recency and a one-hit wonder always displaces a resident entry.
It fails open in every ambiguous direction — a refused store is the expensive mistake, an admitted one merely costs a slot:
- the policy is off (the default) → admit;
- the sketch could not be allocated → admit (no evidence is never a refusal);
- probation is empty → admit (there is no victim to be colder than, and a cold zone must be able to fill itself);
- the estimates tie → admit (a count-min estimate over-counts on collision and never under-counts, so equality is the ambiguous case).
Refreshes of an already-resident entry are never subject to it: they displace nobody.
Watch these before and after. admission_refused climbing while the hit
ratio does not improve means the policy is refusing work it should be caching;
turn it back off. sketch_bumps at 0 on a live zone means the sketch was never
allocated and the policy is inert. sketch_gen counts the sketch's aging
halvings.
| JSON field | Prometheus |
|---|---|
admission_refused | cache_turbo_admission_refused_total |
sketch_bumps | cache_turbo_sketch_bumps_total |
sketch_gen | cache_turbo_sketch_gen |
used_bytes | cache_turbo_used_bytes |
What autotune actually tunes
cache_turbo_autotune on makes the cache load-adaptive: every 30s it
measures the window's average backend regeneration cost and hit-rate, and when
the origin is genuinely under load it dials three things — then relaxes them the
first quiet window. Off by default; the freshness contract you configured is
never relaxed.
| What it tunes | Under load | Bounded by | Touches freshness? |
|---|---|---|---|
beta (refresh eagerness) | raised from the measured cost (`beta = cost_ms/20$, \times 1000) \text{so} \text{refreshes} \text{fire} \text{earlier} \text{and} \text{smooth} \text{the} \text{load} | \text{re}-\text{clamped} \text{to} \text{the} \text{location}'\text{s} \text{preset} \text{band} ($beta_min..beta_max`) | no |
| Stale window (serve-stale grace) | widened by a load factor so a stale entry stays serveable longer before becoming a hard miss — fewer origin trips | ≤4× the configured stale window | no — the fresh TTL is untouched; only the best-effort stale grace stretches |
| `lock_ttl$ (\text{single}-\text{flight} \text{window}) | \text{widened} \text{by} \text{the} \text{same} \text{factor} \text{so} \text{a} \text{slow} \text{regen} \text{isn}'\text{t} \text{re}-\text{claimed} \text{mid}-\text{flight} \text{and} \text{more} \text{requests} \text{collapse} \text{onto} \text{it} | ≤4 \times \text{the} \text{configured} $lock_ttl` | no |
The load factor is published per-zone as cache_turbo_autotuned_load (×1000;
1000 = baseline / not under load, up to 4000). It is derived from the same
cost signal as beta — 1× at the moderate-load threshold, rising with a slower
origin, hard-capped at 4× — and snaps back to 1000 the first interval the
backend is no longer under load (low cost or a healthy hit-rate). So a traffic
spike that overwhelms the origin transparently buys the cache more stale-serving
headroom and tighter dogpile control, and it all reverts automatically once the
spike passes. What it will never do is extend the fresh TTL — a client is
never told "fresh" about content older than your cache_turbo_valid contract.
Load-adaptation is folded into
cache_turbo_autotune on— turning autotune on enables all three. If you want only beta tuning with a rock-fixed stale window, you currently get the adaptive stale/lock behaviour too (bounded ≤4×); the fresh-TTL guarantee is unaffected either way.
Full example (the works)
load_module modules/ngx_http_cache_turbo_module.so;
http {
cache_turbo_zone name=ct 512m;
# cluster-shared L2 in Redis (optional). Inherited by everything below.
# keepalive= pools idle Redis conns per worker, removing one TCP connect
# per L2 lookup. See "Redis L2 (shared cache)".
cache_turbo_redis 127.0.0.1:6379 prefix=ct: timeout=250ms
keepalive=32 keepalive_timeout=60s;
server {
listen 80;
server_name example.com;
location / {
cache_turbo ct;
cache_turbo_preset balanced;
cache_turbo_valid 60s;
# collapse junk so ?utm_source=… and arg-reordering hit one slot,
# but split real variants (gzip/brotli, mobile/desktop)
cache_turbo_key $host$uri$cache_turbo_normalized_args;
cache_turbo_normalize_vary encoding device;
# let pages be purged in groups (needs Redis)
cache_turbo_tag $upstream_http_x_cache_tags;
# adapt refresh eagerness to how slow the backend actually is
cache_turbo_autotune on;
proxy_pass http://127.0.0.1:8080;
}
# control panel: stats + purge + warm. LOCK THIS DOWN.
location = /_cache {
cache_turbo_admin ct;
allow 127.0.0.1;
deny all;
}
# don't cache the admin/login area at all
location /wp-admin/ {
cache_turbo off;
proxy_pass http://127.0.0.1:8080;
}
}
}
Using the control panel
# stats (JSON)
$ curl localhost/_cache
{"hits":1240,"misses":83,"stale_serves":12,"refreshes":11,"evictions":0,"l2_hits":61,"l2_misses":22,"bypasses":5,"cost_ms":34,"autotuned_beta":1700,"autotuned_load":1000,"lock_ttl":5}
$ curl -X POST 'localhost/_cache?key=/blog/post-42' # drop one page
{"purged":1}
$ curl -X POST 'localhost/_cache?tag=post-42' # drop everything tagged
{"purged":7}
$ curl -X POST 'localhost/_cache?all=1' # nuke the whole zone
{"purged":312}
$ curl -X POST 'localhost/_cache?url=/,/blog/,/about' # pre-warm cold pages
{"warmed":3}
$ curl -X POST 'localhost/_cache?url_file=/etc/myapp/warm-list.txt' # or from a file, one path per line
{"warmed":3}
The admin location purges the cache and
?url=/?url_file=fire server-side fetches to local paths. Always gate it withallow/deny(or auth). Never public. Both warm sources are capped atcache_turbo_warm_maxURLs per request (default32);?url_file=additionally bounds the file read itself (64 KiB max size, 2048-byte max line length) so an operator-supplied list cannot become an unbounded allocation or an unbounded origin fan-out.
Every directive in one place (full syntax)
A single annotated config that names every directive with valid syntax and its default value, so you can copy a line out and change it. The values shown are the defaults — a block with all of them deleted behaves identically to one with all of them present.
This block is a reference, not a paste-me. A few directives are mutually exclusive or context-restricted (Redis vs memcached, the
http-only zone, thelocation-only admin endpoint) — see the comments and the interaction matrix below. Lift the lines you need into the right context.
load_module modules/ngx_http_cache_turbo_module.so;
http {
# ── http context only ───────────────────────────────────────────────
cache_turbo_zone name=ct 256m; # declare the shm zone (min 8 pages)
# L2 tier — pick AT MOST ONE of the next two (mutually exclusive per block).
# Both also valid at server/location scope; declared here they're inherited.
cache_turbo_redis redis://127.0.0.1:6379/0 prefix=ct: timeout=250ms
tls=off tls_verify=on keepalive=0 keepalive_timeout=60s;
# cache_turbo_memcached 127.0.0.1:11211 prefix=ct: timeout=250ms
# keepalive=0 keepalive_timeout=60s;
server {
listen 80;
server_name example.com;
location / {
# ── turn it on ──────────────────────────────────────────────
cache_turbo ct; # bind zone "ct" (or: off)
cache_turbo_backend wordpress; # presets stack with '|' or spaces; see the directive table/docs for the full list; 'none' = off; implies cache_control honor
# ── what is "the same page" ─────────────────────────────────
cache_turbo_key $host$uri$cache_turbo_normalized_args; # explicit normalized opt-in
cache_turbo_key_encoded_origin off; # require explicit encoded-origin opt-in
cache_turbo_normalize_strip sid sessionid "tmp_*"; # extra args to drop (trailing * = prefix; bare * = all)
cache_turbo_normalize_max_args 64; # cap sorted query args
cache_turbo_normalize_vary encoding device; # add variant buckets to the key
# ── freshness / staleness ───────────────────────────────────
cache_turbo_preset balanced; # micro|conservative|balanced|aggressive — sets the 5 knobs below
cache_turbo_stale_mult 4; # stale window multiplier (balanced)
cache_turbo_valid 60s; # 200 TTL; 0 = cache forever
cache_turbo_valid 301 302 308 1h; # repeatable: cache redirects
cache_turbo_valid 404 410 1m; # negative caching
cache_turbo_beta 1000; # refresh eagerness ×1000
cache_turbo_lock_ttl 5s; # single-flight refresh window
cache_turbo_cache_control respect; # respect | honor (take TTL from response CC/Expires) | ignore (discard response CC)
cache_turbo_background_update on; # SWR + stale-if-error (off = inline regen)
cache_turbo_background_update_max 0; # unlimited concurrent background refreshes
cache_turbo_keep_stale 24h; # off | <time> | forever — serve stale when origin is down
cache_turbo_l2_negative_ttl 0; # disabled
# cache_turbo_use_stale http_404 http_429; # which statuses count as "down". Omitted = the default, ANY 5xx.
# ⚠ naming tokens REPLACES the default, it does not add to it:
# listing the four 5xx tokens drops 501/505/507/... coverage.
cache_turbo_max_size 1m; # max serialized cached object
# ── dogpile / admission control ─────────────────────────────
cache_turbo_lock on; # cold-miss single-flight (others wait for the fill)
cache_turbo_lock_timeout 5s; # how long a waiter waits before going to origin itself
cache_turbo_min_uses 1; # cache only after the key is seen N times (1 = first miss)
cache_turbo_min_uses_window 0; # no counter expiry window
cache_turbo_scan_resistant on; # protected_pct=80 by default
cache_turbo_warm_max 32; # max URLs per warm request
# ── per-request opt-outs ────────────────────────────────────
# Derive bypass/no-store variables from trusted auth state.
# Raw query, header, and cookie values let clients bypass caching.
cache_turbo_bypass $cookie_session; # skip lookup, still store
cache_turbo_no_store $cookie_session; # pair it: do not store response
cache_turbo_bypass_uri /wp-admin/; # optional URI bypass
cache_turbo_bypass_stale_uri /catalog/; # optional breaker fallback
cache_turbo_backend_prefix /shop/; # opt-in example: mounted-app prefix
cache_turbo_key_cookie locale; # opt-in example: variant cookie
# ── let the origin decide (inverts the store default here) ──
cache_turbo_require_header X-GraphQL-Cacheable; # store ONLY if origin affirms
# ── Vary handling ───────────────────────────────────────────
cache_turbo_auto_vary on; # off = ignore response Vary (old Vary-blind behavior)
cache_turbo_vary_ignore X-Device; # optional Vary axis ignore
cache_turbo_vary_marker_revalidate 2s; # L2 marker revalidation interval
# ── tags: local purge-by-tag (Redis) + downstream CDN sync ──
cache_turbo_tag $upstream_http_x_cache_tags; # purge-by-tag index needs cache_turbo_redis
cache_turbo_surrogate_key off; # on = re-emit the tags as a Surrogate-Key header for a fronting CDN (no Redis needed)
# ── L2 grouping / tuning ────────────────────────────────────
cache_turbo_autotune off; # on = derive beta from measured backend latency (fixed 30s cadence)
# ── stacking with native proxy_cache ────────────────────────
cache_turbo_suppress_native off; # on = drive $cache_turbo_active for proxy_no_cache
cache_turbo_serve_authorized off; # do not read shared entries with Authorization
cache_turbo_store_head off; # HEAD_DERIVED population is opt-in
cache_turbo_breaker_count_retries off; # count only the final upstream attempt
cache_turbo_breaker on; # outage breaker enabled
cache_turbo_breaker_threshold 5; # failures to trip
cache_turbo_breaker_window 10s; # rolling failure window
cache_turbo_breaker_open 30s; # open duration
cache_turbo_breaker_retry_after 30s; # advisory retry hint
proxy_pass http://127.0.0.1:8080;
}
# A compatible opt-in example for named non-identifying cookies.
# Cookie-ignore is only safe without a backend preset or cookie-value
# keying, so this location selects `cache_turbo_backend none`.
location /cookie-safe/ {
cache_turbo ct;
cache_turbo_backend none;
cache_turbo_ignore_set_cookie _ga;
proxy_pass http://127.0.0.1:8080;
}
# ── location context only ───────────────────────────────────────
location = /_cache {
cache_turbo_admin ct; # stats / purge / warm endpoint
cache_turbo_purge on; # also accept PURGE <uri>
allow 127.0.0.1;
deny all; # NEVER public
}
}
}
Mutually exclusive / interacting directives
| Pair | Relationship | What happens |
|---|---|---|
cache_turbo_redis ↔ cache_turbo_memcached | hard error | One L2 per block. Declaring both in the same block fails the config at start ("the two are mutually exclusive"). |
cache_turbo_tag → cache_turbo_redis | required for purge-by-tag only | The local purge-by-tag index needs Redis sorted-sets; with memcached or no L2 it is unavailable and cache-turbo warns at config time (memcached has no tag/?all/cross-node lock). The tag is not wasted without Redis if cache_turbo_surrogate_key on is set — the tags are then emitted downstream as a Surrogate-Key header for a fronting CDN (no Redis needed, no warning). |
cache_turbo_auto_vary ↔ cache_turbo_normalize_vary | don't double-cover an axis | Not an error, but keying the same axis (e.g. encoding) via both multiplies the slot count for no benefit. Pick one per axis. |
cache_turbo_preset ↔ cache_turbo_valid/_beta/_lock_ttl | explicit wins | The preset sets a band of defaults; any explicit knob overrides just that knob (the rest stay at the preset). Not exclusive. |
cache_turbo_backend → cache_turbo_cache_control | implies | Enabling any CMS auto-classify preset defaults cache_turbo_cache_control to honor unless you set it explicitly. none does not — asking for no classification should not quietly change how Cache-Control is treated. |
cache_turbo_background_update off → stale-if-error / SWR | disables | Inline regeneration replaces serve-stale-while-revalidate; a stale entry is no longer served during refresh, and stale-if-error no longer applies. |
Directive synopsis
| Directive | Context | Default | What it does |
|---|---|---|---|
cache_turbo_zone name=NAME SIZE | http | — | Declare a shared-memory cache zone (min 8 pages). |
cache_turbo NAME / off | server, location | off | Turn caching on (bind a zone) or off. Takes a zone name and nothing else — the old auto shorthand is gone (see cache_turbo_backend). |
cache_turbo_backend NAME... | server, location | — | Auto-classify dynamic (uncacheable) request surfaces for one or more application presets: wordpress, woocommerce, joomla, xenforo, discourse, phpbb, drupal, mediawiki, magento, shopware6, ghost, wagtail, kirby, typo3, invision, smf, vanilla, punbb, phorum, yabb, mybb, vbulletin, textpattern, bludit, spip, bugzilla, mantisbt (mantis), plone, umbraco, dotclear, wikijs, redmine, flarum, opencart; aliases: classicpress → wordpress, backdrop → drupal; or none. A matching request (login/session cookie, admin URI, dynamic arg) skips lookup and storage and goes straight to origin. Every preset is opt-in; names stack, separated by spaces or | (wordpress|woocommerce == wordpress woocommerce). Implies cache_turbo_cache_control honor. none means no preset here and exists to override one inherited from the server block; it is exclusive and does not imply honor. generic/auto were removed and are now a config error — the union was never a safe default (why). Cookie names that an app lets you rename still need an explicit local rule; see each application guide. There is no django/laravel preset and never will be (why); Jira, Request Tracker and several other session-eager trackers are intentional non-presets (research). |
cache_turbo_suppress_native on | server, location | off | Make $cache_turbo_active read 1 while cache-turbo owns a request, so a stacked native proxy_cache can defer via proxy_no_cache $cache_turbo_active; proxy_cache_bypass $cache_turbo_active;. Off (default) keeps the variable always 0 (the wiring stays inert). |
cache_turbo_key STRING | server, location | raw | What makes two requests "the same page". The built-in default is the Host header + raw unparsed path/query (not $host$uri$query_string — closer to $host$request_uri, since unparsed_uri is undecoded), with no argument normalization and no scheme/port. To enable normalized matching, set it to $host$uri$cache_turbo_normalized_args; to separate HTTP/HTTPS entries, use $scheme$host$request_uri. |
cache_turbo_preset NAME | server, location | balanced | micro / conservative / balanced / aggressive — sets the five knobs below at once (valid, beta, lock_ttl, stale_mult, min_uses). micro = 1s microcaching (valid 1s, lock_ttl 1s, ×2 stale). |
cache_turbo_valid [CODE...] TIME | server, location | preset (60s) | How long a copy stays fresh (then stale, still served). Bare TIME = the default/200 TTL. TIME of 0 = cache forever (stays fresh, never expires). With leading status codes (cache_turbo_valid 301 404 1m;) it makes those statuses cacheable too — redirects + negative caching. Repeatable. |
cache_turbo_beta N | server, location | preset (1000) | Refresh eagerness, ×1000 (1000 = 1.0). Higher = refresh sooner/more often. |
cache_turbo_lock_ttl TIME | server, location | preset (5s) | Single-flight window: once one refresh is claimed, others serve stale until it finishes. Caps backend regens to ~one per cycle. 0 is rejected at config time (use cache_turbo_lock off to disable single-flight instead); an oversized value is clamped rather than rejected. |
cache_turbo_stale_mult N | server, location | preset (4 balanced) | Stale window as a multiple of the fresh TTL: an entry stays serveable as STALE until cache_turbo_valid * N. 1 = no stale window (hard-expire at the fresh TTL); the maximum is 8. An explicit value overrides the preset's band, like cache_turbo_valid/_beta/_lock_ttl. 0 is rejected rather than silently meaning 4. |
cache_turbo_lock on / off | server, location | on | Cold-miss single-flight: when an uncached key is hit by many requests at once, the first goes to the origin and the rest wait for it to fill the cache (per box via a stub, cluster-wide via the Redis lock) rather than all stampeding the origin. Off = every cold miss goes straight to the origin. |
cache_turbo_key_encoded_origin on|off | server, location | off | Mark origin responses as already content-encoded and require automatic Vary keying so an encoded body is never served to an incompatible client. |
cache_turbo_serve_authorized on|off | server, location | off | Permit requests carrying Authorization to read shared entries; storage remains subject to the authorization safety floor. |
cache_turbo_store_head on|off | server, location | off | The original HEAD response is never captured. On a HEAD miss, issue one internal background GET; that GET creates a HEAD_DERIVED entry, which is refused to non-HEAD requests, so it cannot satisfy an ordinary GET. |
cache_turbo_lock_timeout TIME | server, location | 5s | How long a waiting cold-miss request waits for the winner's fill before giving up and going to the origin itself. |
cache_turbo_min_uses N | server, location | preset (1, 2 aggressive) | Cache a page only after its key has been requested N times — keep one-hit-wonder URLs out of the cache. Below the threshold each request goes to the origin and is not stored; the N-th miss stores it. A key already present in the L2 (Redis or memcached) tier is served from L2 regardless (it is already proven popular). 1 = store on the first miss (off), which is the band value for every preset except aggressive (2). An explicit value overrides the preset's band, like cache_turbo_valid/_beta/_lock_ttl/_stale_mult. Range 1..32; 0 and negatives are rejected at config time rather than silently meaning 1. Note each sub-threshold miss still costs a lightweight counter node in the zone — min_uses saves the response body, not the key slot, so raising it on a site without a long tail is a pure origin-load increase. |
cache_turbo_min_uses_window TIME | server, location | 0 (off) | Reset the miss counter when the node has aged past this window, so misses are counted only within a recent time window instead of accumulating for the node's lifetime. Without a window, two misses separated by days count the same as two a second apart — genuine one-hit-wonders that happen to recur between distinct intervals still satisfy min_uses 2 and are cached; with a window, they are treated as fresh cold counts and must re-cross the min_uses threshold. Only matters when cache_turbo_min_uses > 1 AND cache_turbo_min_uses_window > 0 are both set; at the default min_uses 1 or default min_uses_window 0 there is no windowing, and behavior is identical to v15 (lifetime counter). Range 0 (off) or 1..86400; off by default (0 = no windowing, counter persists until LRU eviction). Negatives are rejected at config time. Misses are tracked per-key in a lightweight counter node; on each miss, if now - last_access > window the counter resets to 0 before incrementing, forcing the miss count to start fresh. The window applies per-key and per-access, not globally. On a site with a long-tail workload, enabling this may increase origin load by forcing lower-popularity URLs to re-cross min_uses more often. Size it to your cache eviction pressure: typical value is 3600 (1 hour), or disable it if your zone stays cold between visits (e.g. internal APIs, low-traffic sites). |
cache_turbo_scan_resistant on|off [protected_pct=N] | server, location | on protected_pct=80 (P3-1+; was off) | Split the LRU into PROBATION and PROTECTED segments so a crawler walking a large unique keyspace cannot evict the hot set. A new store enters probation; a second hit promotes to protected; eviction takes the probation tail first and only falls through to protected when probation is empty. protected_pct caps the protected segment as a percentage of resident entries (1..99, default 80); on overflow the protected tail is demoted to the probation head rather than dropped. Counter/stub/memo nodes never promote (they hold no body). Explicit off is byte-for-byte the pre-P3-1 flat LRU; omitting the directive now means on (see Upgrading note above). protected_pct=0, values outside 1..99, non-numeric values, and protected_pct combined with off are all rejected at config time rather than silently coerced. Changes eviction ORDER only -- never what is served, what is cacheable, or any freshness decision. |
cache_turbo_l2_negative_ttl N | server, location | 0 (off) | Remember for N seconds that an L2 (Redis/memcached) GET missed a key, so the next cold request for it skips the L2 round-trip instead of paying a full RTT to be told "absent" again. Off by default and not a preset band value — it must be opted into explicitly, because it trades L2 coherence for that saved round-trip. Like every other server-scoped directive here it inherits: set it at server level and every location under it that does not override it gets the same window, so scope it to the locations you mean rather than assuming it stays where you wrote it. Bounded staleness by construction: nothing invalidates the memo when a peer node stores the key, so for up to N seconds this node may go to the origin for an object L2 actually holds. That is a hit-rate cost, never a correctness one — the memo carries no body and can never cause a stale serve. Keep N at a few seconds (the cold-key stampede it collapses is far shorter) and leave it off unless L2 misses dominate your miss path. Range 0 (off) or 1..60; negatives are rejected at config time. Watch cache_turbo_l2_neg_skips_total against cache_turbo_l2_hits_total — rising skips with falling L2 hits means the window is too long. |
cache_turbo_max_size SIZE | server, location | 1m | Maximum serialized cached object size (metadata, stored headers, and body). Range: 1 byte..64 MiB; 0 and larger values are rejected at config time. |
cache_turbo_bypass VAR... | server, location | — | If any variable is non-empty and not 0, skip the cache lookup (go to origin) — but still store the fresh response. E.g. cache_turbo_bypass $cookie_session; to always revalidate logged-in users. Never put a client-controlled variable ($arg_nocache, $http_x_no_cache, …) in this list on a public endpoint — the bypass returns before the single-flight lock is taken, so miss-collapsing does not apply and ab -n 200000 'https://site/?nocache=1' puts 100% of a flood on the origin. If you need a manual cache-buster, gate it on something only you can send (a shared secret in a header, or $remote_addr via a map). On an identity predicate (session/login cookie, auth token, private-surface flag) always pair it with the same variables on cache_turbo_no_store — on its own a bypass still writes that user's personalised response under the shared key, where the next anonymous visitor can be served it. Bypass controls the lookup; cache_turbo_no_store controls the store. (cache_turbo_bypass_uri and the cache_turbo_backend presets do skip storing as well; only cache_turbo_bypass does not.) |
cache_turbo_no_store VAR... | server, location | — | If any variable is non-empty and not 0, do not store the response. E.g. cache_turbo_no_store $cookie_session;. |
cache_turbo_bypass_uri PREFIX... | server, location | — | Skip the cache entirely for requests whose URI starts with any of the listed prefixes — neither served from nor stored into the cache. Each prefix must start with / (a config error otherwise) and is matched against r->uri with a / or . boundary. r->uri is the decoded path without the query string, so a prefix containing ? can never match — use cache_turbo_bypass with a map $args for query-string state. It is also the post-redirect path: behind a front controller (try_files $uri /index.php) r->uri is /index.php by the time this runs, so a prefix written against the clean route matches nothing and the private page is cached. There, guard with cache_turbo_bypass + cache_turbo_no_store driven by a map $request_uri instead. Prefixes are anchored at byte 0 and are not rebased by cache_turbo_backend_prefix (they are your own literals, written against the deployed path), so on a subdirectory install give them the full mounted paths. |
cache_turbo_bypass_stale_uri PREFIX... | server, location | — | Allow requests whose URI starts with any of the listed prefixes to be stored as circuit-breaker fallback only, so a dead origin can be answered from the last good copy instead of a 503. Same /-anchored, /-or-.-boundary matching as cache_turbo_bypass_uri, and the same non-rebasing rule under cache_turbo_backend_prefix. The stored entry is flagged breaker-only and is unreachable on the normal path at any age — it is served only while the circuit breaker is OPEN, tagged X-Cache: STALE-BREAKER, never as a HIT or STALE. This is deliberately a separate opt-in, not a flag on your bypass rules: cache_turbo_bypass/cache_turbo_bypass_uri keep meaning "never cache this", and a URL excluded by them does not become breaker-serveable by matching some other rule. ⚠ Only name surfaces that are shared by construction (a public catalog or inventory API). The breaker serves one stored body to every client, so pointing this at an authenticated or per-user response is a cross-user disclosure — for those, the correct answer remains a plain bypass (no fallback), or cache_turbo_key_cookie value-keying if the variation is a shared segment fingerprint rather than an identity. |
cache_turbo_backend_prefix PATH | server, location | — | Declare the mount point of a subdirectory install (cache_turbo_backend_prefix /shop/; for a WordPress served from https://example.com/shop/). The cache_turbo_backend presets ship URI rules as root-relative literals (/wp-admin/) matched from byte 0, so without this a mounted app matches no URI rule at all and its admin surface is cacheable. When set and the request URI starts with PATH, the preset URI tier compares against the URI with the mount removed — /shop/wp-admin/ is tested as /wp-admin/. Must begin and end with / (a config error otherwise; bare / is rejected as a no-op). A URI outside the mount is left alone rather than force-matched. Scope is the preset URI tier only: cache_turbo_bypass_uri prefixes are your own literals and are untouched, and the cookie and arg tiers are path-independent. Unrelated to the prefix= parameter on cache_turbo_redis/cache_turbo_memcached, which namespaces cache keys. |
cache_turbo_key_cookie NAME... | server, location | — | Fold the value of each named cookie into the cache key, so each distinct value gets its own entry. Names are matched exactly (not as a substring). Each value is added length-prefixed, which is why this is the correct way to key on a cookie: splicing $cookie_* variables into cache_turbo_key by hand leaves the fields undelimited and lets a visitor choose a value that reproduces another page's key. Use for variants (theme, language, currency); never for identity — see cache_turbo_bypass. Values longer than 256 bytes are not folded verbatim: they all collapse into a single oversize bucket, distinct both from the absent-cookie (anonymous) entry and from every in-range value, so an over-long value can neither poison the anonymous entry nor land on a real segment's. A cap does not bound cardinality in general — for that use cache_turbo_min_uses. ⚠ Keep the list short. Each name drives its own scan of every Cookie: header, so the hit-path cost is names × cookie-headers, not names, and it is paid on a cache HIT too. There is deliberately no cap: the name count is operator-chosen and the header count is already bounded by large_client_header_buffers, so neither factor is attacker-chosen and a limit would only break working configs — contrast cache_turbo_normalize_max_args, which caps a count the client picks. Note the directive appends rather than replaces across repeats, and inherits into nested locations on top of any cache_turbo_backend preset's own key cookies, so the effective list can be longer than any one line suggests. |
cache_turbo_ignore_set_cookie NAME... | server, location | — | Name the cookies whose presence in a response must not block caching — the module's equivalent of nginx proxy_ignore_headers Set-Cookie, except named rather than blanket. By default a response carrying any Set-Cookie is never stored (it is assumed to carry per-client state), which on a CMS or analytics stack that staples _ga, an A/B bucket or a consent flag onto otherwise shareable HTML makes every response uncacheable. List those cookie names here and such a response becomes storable. Fail-closed at every ambiguity: the store is allowed only when every Set-Cookie in the response names a listed cookie — one unlisted name, or one value that does not parse to exactly one RFC 6265 token cookie name (no =, an empty or quoted name, an embedded space), refuses the store exactly as before. Names are matched exactly and case-sensitively against the cookie name only, never against the attributes, so Set-Cookie: sessionid=x; Path=/_ga is not matched by a listed _ga. The stored entry never carries the cookie: Set-Cookie is always stripped before serialisation, so a HIT can never replay one client's cookie to another. ⚠ Vetoed entirely — the directive does nothing — whenever any cookie value-keying is configured in the location, i.e. any cache_turbo_backend preset is active or any cache_turbo_key_cookie is set. Value-keying means a request without the key cookie hashes to the anonymous entry while the response establishes the segment; storing that body under the anonymous key would poison it for every anonymous visitor. Put this directive only on locations that carry no key cookie (add cache_turbo_backend none if a preset is inherited from server level). ⚠ Only list cookies that are non-identifying by construction. Listing a session or login cookie is a cross-user disclosure of the response, not of the cookie — for identity, cache_turbo_bypass remains the correct answer. |
cache_turbo_require_header NAME | server, location | — | Inverts the store default on this location: nothing is stored unless the response carries NAME with an affirmative value (yes/1/on, case-insensitive, matched whole). Header absent, no, empty, or sent twice with conflicting values ⇒ no store — it fails closed. For origins HTTP cannot judge: a GraphQL endpoint answers queries and mutations on one URI+method and returns errors as 200 (details). Takes a header name, not a $variable (the value is read from the response; a variable would evaluate against the request), validated at config time. Stripped before storing, so a hit never replays it. Unset ⇒ inert. |
cache_turbo_purge on | server, location | off | Allow a PURGE <uri> request to drop that URI's entry from L1 (+L2). Gate the location with allow/deny. E.g. curl -X PURGE http://host/blog/post-42. |
cache_turbo_cache_control respect|honor|ignore | server, location | respect | How the response Cache-Control is treated. respect (default): it gates storage and reshapes the stale window as written; the fresh TTL comes from cache_turbo_valid. honor: also take the fresh TTL from the response's own freshness headers, in RFC 9213 precedence order — Surrogate-Control: max-age (Fastly/Akamai) > CDN-Cache-Control: s-maxage/max-age (Cloudflare) > Cache-Control: s-maxage/max-age > Expires — falling back to cache_turbo_valid when none is present. The two targeted headers let an origin hand this shared cache a different TTL than the browser's Cache-Control; a targeted no-store/private/max-age=0 also vetoes storage, and both targeted headers are stripped before store so they never replay downstream (see Behind a CDN / multi-tier caching). ignore: discard the response Cache-Control entirely (mirror of nginx's proxy_ignore_headers Cache-Control) — no-store/no-cache/private/max-age=0/s-maxage=0 no longer forbid storage, must-revalidate/proxy-revalidate/stale-while-revalidate=N/stale-if-error=N no longer reshape the window (it stays cache_turbo_valid × cache_turbo_stale_mult), and the TTL comes from cache_turbo_valid; use it for an origin that blankets shareable pages with max-age=0, must-revalidate. The Set-Cookie and request-Authorization safety floors are not affected by any mode — a per-user response is still never cached. A CMS preset (cache_turbo_backend) defaults this to honor. |
cache_turbo_background_update on / off | server, location | on | The stale-while-revalidate behaviour. On (default): a stale page is served immediately while one request quietly refreshes it in the background — nobody waits on the backend, and if that refresh hits a 5xx/timeout the old copy is left untouched and keeps being served (stale-if-error). Off: the chosen refresher regenerates inline (it waits for the backend and serves the fresh body), the pre-SWR behaviour. |
cache_turbo_background_update_max N | server, location | 0 (unlimited) | Cap concurrent background refresh subrequests per shared zone; 0 leaves the cap unlimited. |
cache_turbo_keep_stale off | <time> | forever | server, location | 24h | Origin-independent last-resort retention. When a cached entry has fully expired and the origin is DOWN, serve the stale copy if the cache_turbo_keep_stale window covers the request. Default 24h: outage resilience out of the box. off: no grace window, errors surface normally. <time> (e.g. 6h): serve stale for up to that long, clamped to the module's internal TTL ceiling. forever: never give up the stale copy. Argument forms: a bare 0 is also off (unlike cache_turbo_valid 0, which means cache forever — note this! A bare 0 here is deliberately the OPPOSITE.) Precedence (decision D-1): a honored response stale-if-error (RFC 5861) wins outright over cache_turbo_keep_stale — it is not a max() of the two. cache_turbo_cache_control ignore does not disable cache_turbo_keep_stale; it makes the upstream Cache-Control inert, but keep_stale is operator configuration. A honored must-revalidate from the response DOES suppress keep_stale — must-revalidate forbids any stale serve at all, stale-if-error included. |
cache_turbo_use_stale off | error | timeout | http_403 | http_404 | http_429 | http_500 | http_502 | http_503 | http_504 ... | server, location | every 5xx | Which upstream statuses count as "the origin is down" and so may be answered from a stale cached copy. Multiple tokens may be listed in one directive. The default is every 5xx (including ones no token names, e.g. 501, 505, 507, 508, 510, 511), so omitting the directive keeps the pre-existing behaviour byte-for-byte. ⚠ Naming any token REPLACES that default rather than extending it — cache_turbo_use_stale http_500 http_502 http_503 http_504 reads like the default written out, but it is strictly narrower, because the unnamed 5xx statuses are covered by an internal ANY_5XX bit that no token can set. off disables serve-on-error entirely and is only accepted on its own — listing it alongside any other token is a config error. This selects the TRIGGER; the retention WINDOW still comes from a response stale-if-error or from cache_turbo_keep_stale, so a status named here is only served stale while such a window is open. ⚠ error and timeout are not nginx-equivalent here. In proxy_cache_use_stale they are communication-failure classes (error = the connection failed, as opposed to an upstream that really answered 502). This module triggers in the response header filter, which sees only the final status, so a refused connection and a genuine 502 are indistinguishable at that point: error behaves as http_502 and timeout as http_504. The tokens exist so the configuration vocabulary matches nginx's, and they carry their own bits so a future trigger site with access to upstream state can honour the distinction without a config break. |
cache_turbo_breaker on | off | server, location | on | Turn the circuit breaker on for this location, independently of cache_turbo itself. The breaker consults cache_turbo_breaker_threshold/cache_turbo_breaker_window before it can ever trip, and cache_turbo_breaker_open for how long an OPEN trip lasts before probing the origin again. Shipped on by default for outage resilience. Three independent ways to express "off": this flag, cache_turbo_breaker_threshold 0, or cache_turbo_breaker_window 0 — any one alone disables the breaker. There is no cache_turbo_breaker_probe directive: the probe lease is a fixed internal constant, not operator-tunable. ⚠ Breaker state is PER ZONE: all locations sharing cache_turbo_zone share one breaker. See Circuit breaker isolation: per-zone, not per-upstream for blast radius and remedy. |
cache_turbo_breaker_threshold N | server, location | 5 | Consecutive origin failures (5xx, within the rolling cache_turbo_breaker_window) needed to trip the breaker OPEN. 0 disables the breaker — see cache_turbo_breaker above for the other two off-switches. |
cache_turbo_breaker_count_retries on|off | server, location | off | When enabled, count failed upstream peer attempts that precede the final response toward the per-zone breaker; off preserves final-attempt-only behavior. |
cache_turbo_breaker_window TIME | server, location | 10s | The rolling window failures are counted over. 0 disables the breaker (a threshold with no window is meaningless), same as cache_turbo_breaker_threshold 0. |
cache_turbo_breaker_open TIME | server, location | 30s | How long an OPEN breaker lasts before promoting exactly one request to probe the origin. 0 is a hard config error, not a disable — open_for > 0 is what lets an OPEN breaker ever reopen; 0 would wedge it OPEN permanently (no probe promoted, and with no origin contact while OPEN, no success can ever close it either). To disable the breaker use cache_turbo_breaker off, cache_turbo_breaker_threshold 0, or cache_turbo_breaker_window 0 instead. |
cache_turbo_breaker_retry_after TIME | server, location | cache_turbo_breaker_open's effective value | Advisory Retry-After seconds sent with the breaker's 503. Left unset, it tracks the effective cache_turbo_breaker_open so the hint matches the actual next-probe timing; set explicitly to override. |
cache_turbo_autotune on | server, location | off | Adapt to live backend load. Auto-picks beta$ \text{from} \text{the} \text{measured} \text{regen} \text{latency} (\text{clamped} \text{to} \text{the} \text{preset}'\text{s} \text{band}) **\text{and}**, \text{under} \text{sustained} \text{load}, \text{widens} \text{two} \text{knobs} \text{by} \text{a} \text{load} \text{factor} (≤4 \times ): \text{the} **\text{serveable} \text{stale} \text{window}** (\text{serve} \text{stale} \text{longer} \text{before} \text{a} \text{hard} \text{miss}) \text{and} \text{the} **\text{single}-\text{flight} $lock_ttl** (collapse more requests onto one regen). The fresh TTL is never touched — the freshness contract you set is unchanged; only the best-effort stale grace and dogpile window stretch, and they snap back the first quiet window. Recomputes on a fixed 30s cadence. See What autotune does. |
cache_turbo_redis DSN [opts...] | http, server, location | — | Add a shared L2 Redis tier. DSN is redis://[user:pass@]host:port/db (or bare host:port); rediss:// = TLS. Write-through on store; one sync GET on an L1 miss (never on an L1 hit). Opts: prefix= (ct:, non-empty, printable ASCII without spaces or control characters, <=186 bytes), timeout= (250ms), connect_backoff= (2s; 0 disables connection-failure backoff), password=, user=, db=, tls=on|off, tls_verify=on|off (default on), tls_ca=<file>, tls_name=<host>, keepalive=N (idle conns to pool per worker, 0=off), keepalive_timeout= (60s), scan_deadline= (30s; wall-clock ceiling on one ?all=1 L2 SCAN walk, checked at every page boundary alongside the fixed page cap — a backend that always returns a non-zero cursor otherwise parks the request for up to the page cap's worth of pages; 0 disables it, page-cap-only). Pooled conns are reused only within the same db/credentials/TLS context. keepalive=/keepalive_timeout= are honoured per connection profile — each distinct backend gets its own per-worker pool sized from its own location (soft cap 16 profiles/worker). Native client, no hiredis. |
cache_turbo_memcached HOST:PORT [opts...] | http, server, location | — | Add a shared L2 memcached tier (alternative to cache_turbo_redis, mutually exclusive with it). Write-through on store; one sync get on an L1 miss. Opts: prefix= (ct:, non-empty, printable ASCII without spaces or control characters, <=186 bytes — a space or control byte would split the delimiter-framed memcached command, so it is refused at config time), timeout= (250ms), connect_backoff= (2s; 0 disables connection-failure backoff), keepalive=N (idle conns to pool per worker, 0=off), keepalive_timeout= (60s). Pooled conns are keyed by peer address (memcached has no db/credentials/TLS) and only re-pooled when the previous reply framed cleanly at a boundary. No tags / ?all / cross-node lock (memcached lacks sorted sets, SCAN, atomic SET-NX); 1 MiB value cap. Native client, no libmemcached. |
cache_turbo_tag EXPR | server, location | — | Tag stored pages (whitespace/comma list) so they can be purged as a group. Needs cache_turbo_redis. |
cache_turbo_surrogate_key on|off | server, location | off | Emit the cache_turbo_tag list downstream as a Surrogate-Key header on the MISS/store response and on every HIT/STALE serve (a CDN POP can refill from a hit), so a fronting CDN (Fastly, …) can purge-by-tag in sync. No cache_turbo_redis required. See Purge-syncing the CDN. |
cache_turbo_admin NAME | location | — | Make this location a control endpoint for zone NAME (stats/purge/warm). Gate with allow/deny. |
cache_turbo_warm_max N | server, location | 32 | Ceiling on how many URLs one POST /_cache?url=... or ?url_file=... request may fire warm subrequests for — bounds the operator-supplied origin fan-out a single admin call can trigger. N must be a positive integer up to 4096; 0, negatives and non-numeric values are rejected at config time rather than silently coerced. |
cache_turbo_normalize_strip NAME... | server, location | — | Extra query args to drop from $cache_turbo_normalized_args (trailing * = prefix; a bare * matches every name = drop all), on top of the built-ins. |
cache_turbo_normalize_max_args N | server, location | 64 | Cap on how many kept (post-strip) query params $cache_turbo_normalized_args will sort. Above the cap normalization is skipped and the raw query string is keyed instead — the request is still served and still keys consistently, it just is not order-/junk-normalized. Bounds the O(n²) sort an unauthenticated request can trigger before the cache lookup. 0 = unlimited (no cap). |
cache_turbo_normalize_vary TOKEN... | server, location | off | Append a variant bucket to $cache_turbo_normalized_args: encoding (br/gzip/identity) and/or device (mobile/desktop). |
cache_turbo_auto_vary on|off | server, location | on | Read the response's own Vary header and split the cache by the named request header automatically. Safe whitelist: Accept-Encoding, User-Agent (device class), Accept-Language (primary-subtag class), Origin (raw — CORS boundary, never folded). Vary: */Cookie/Authorization — or any other header not on the whitelist — ⇒ uncacheable (so an un-split Vary axis can never serve the wrong representation). Two-level keying with node-local L1 markers and optional configured L2 marker recovery. See Auto-Vary. |
cache_turbo_vary_marker_revalidate TIME | server, location | 2s | Revalidate an auto-Vary marker through L2 at most once per interval; 0 restores unconditional local-marker trust. |
cache_turbo_vary_ignore HEADER... | server, location | off (empty) | Drop the named header(s) from a response's Vary line before cache_turbo_auto_vary's whitelist/unknown-axis check — the ignored token is treated as if the origin never listed it: it does not contribute to the variant key and is not promoted into the safe whitelist. Case-insensitive. Only takes effect when cache_turbo_auto_vary is also on. Cache-correctness override, not a free win — only ignore an axis you have verified does not select a different body for your traffic; see the warning in Auto-Vary. *, Cookie and Authorization are rejected at config time — their veto cannot be disabled this way. |
The breaker is per-ZONE state driven by per-LOCATION policy — so its reopen timing is "last reader decides". The failure counter, the window anchor and the OPEN/HALF_OPEN state all live in the shared zone (
cache_turbo_zone name=…), but every breaker directive is alocation(orserver) setting. Two locations on one zone therefore accumulate into one counter pair, while each contributes its own thresholds.
cache_turbo_breaker_openis the one that surprises people. It is not stored in the zone: it is passed per request from the requesting location's own config into the state check, and tested there against the zone-sharedbreaker_opened_at. So whether an OPEN breaker is ready to promote a probe is judged using whichever location'sbreaker_openthe current request landed on — not a merge of the two, not the value in force when it tripped. Give two locations on one zone30sand5mand the effective reopen delay flaps with your traffic mix.cache_turbo_breaker_retry_afterinherits the same property, since it defaults to the effectivebreaker_open.Sharing a zone between locations that want different breaker policy is therefore a configuration mistake — but not a silent one. The module compares each location's effective breaker tuple (
threshold,window,open,retry_after) against the first one it saw for that zone, and warns at config time when they diverge:nginx: [warn] cache_turbo circuit breaker: this location's effective policy (threshold=3 window=10 open=300 retry_after=30) diverges from another location sharing the same cache_turbo_zone (threshold=3 window=10 open=30 retry_after=30); breaker STATE is per-zone but policy is per-location, so whichever location last calls the state machine decides effective reopen timing for the whole zoneIt is a warning, not a rejection — divergence is legal, and configs doing it deliberately keep loading. The module still cannot tell "deliberately layered" from "accidentally inconsistent"; it can only tell you the two policies differ and let you decide. One effective breaker policy per zone — if two locations genuinely need different thresholds or reopen timing, give them separate zones.
Only locations where the breaker is actually live are compared: a location with
cache_turbo_breaker offcontributes no policy and triggers no warning.Enforcement scope is not observation scope.
cache_turbo_breaker offon a location stops that location arming, consulting, tripping, or recording — it does not hide the zone's breaker state from anything else. The admin endpoint still reportsbreaker_stateandbreaker_opensfor the zone, and a sibling location with the breaker on still trips and still serves fallbacks from shared counters this location's traffic never contributed to. A zone whose breaker looks OPEN in the stats while one location behaves as if nothing is wrong is both of these rules working as designed.Sharing a zone also cuts the other way, and this direction is the easier one to miss: healthy traffic from one backend can mask a failure in another. The trip test is a run of consecutive failures: the count must reach
cache_turbo_breaker_threshold(default5) within the rollingcache_turbo_breaker_window(default10s), and a success clears the run. When a failing upstream shares a zone with a busy healthy one, the healthy responses are interleaved into the same shared counter and keep resetting it, so the failing backend may never accumulate an unbroken sequence long enough to trip — the breaker can stay CLOSED against a genuinely dead backend. See Circuit breaker isolation: per-zone, not per-upstream for the opposite direction (one dead backend tripping healthy ones) and the one-zone-per-upstream remedy.
Variables
| Variable | Value |
|---|---|
$cache_turbo_normalized_args | The request's query string with tracking params stripped and the rest sorted, plus the optional Vary bucket (cache_turbo_normalize_vary). It affects cache identity only when selected by an explicit cache_turbo_key; the built-in key uses the raw request URI. |
$cache_turbo_active | 1 when cache-turbo is engaged for this request (enabled, cacheable method, main request) and cache_turbo_suppress_native on; else 0. Wire it into a stacked proxy_cache via proxy_no_cache/proxy_cache_bypass so the native cache defers. |
$cache_turbo_beta | The effective refresh beta ×1000 in force for this request (preset/explicit/autotuned). Handy for debugging/logging. |
$cache_turbo_status | The per-request serve outcome, for access logging. Tokens mirror nginx's $upstream_cache_status so the two graph together: HIT (served fresh), STALE (served stale while refreshing, incl. stale-if-error), EXPIRED (a cached entry was found past its serveable window and refetched from origin), MISS (no serveable entry anywhere → origin, or an only-if-cached request the cache couldn't satisfy → 504), BYPASS (cache_turbo_bypass or a CMS backend preset skipped to origin). - when cache-turbo never engaged. E.g. log_format ct '$request "$cache_turbo_status" rt=$request_time';. |
$cache_turbo_serve_reason | The UNFOLDED per-request serve outcome — like $cache_turbo_status but without its HIT/non-HIT fold, for finer-grained logging. One of five values: FRESH (served fresh — $cache_turbo_status would say HIT), STALE (served stale while refreshing), STALE-IF-ERROR (RFC 5861 serve-on-error replacement), STALE-BREAKER (served stale because the breaker is OPEN/HALF_OPEN), BREAKER-503 (breaker OPEN with no serveable copy — local 503, origin never contacted). - when cache-turbo never engaged, or a request reached MISS/BYPASS/EXPIRED (no unfolded reason for those yet). |
Admin endpoint verbs
| Request | Effect |
|---|---|
GET /_cache | JSON stats. Read-only: legacy ?autotune=1 is ignored on safe methods. |
POST /_cache?action=autotune&value=1 | Force an immediate autotune recompute, then return the same JSON stats body a GET /_cache would. |
GET /_cache?format=prometheus | Same stats in Prometheus text format — scrape this. |
POST /_cache?all=1 | Purge the whole zone (and the L2 keyspace, if Redis is on). L1 walks a finite snapshot-sized budget so concurrent refill cannot starve the worker; if entries remain after that budget, the response is 500 with {"purged":N,"l1":"incomplete"} (retry the purge). A configured L2 purge still runs after this L1 outcome. The L2 side is a SCAN MATCH <prefix>* walk; if that walk does not finish — read timeout, malformed reply, or the internal page cap — the response is 500 with {"purged":N,"l2":"incomplete","reason":"…"} and part of L2 still holds entries. If the walk cannot even start — L2 unreachable, connect refused — the response is 500 with {"purged":N,"l2":"unavailable"} and L2 is untouched. When both tiers are incomplete, the same object contains both "l1":"incomplete" and the applicable "l2"/"reason" fields. A 200 means L1 was empty at its final lock-held observation and, when Redis is configured, the L2 walk reached the end of the keyspace. |
POST /_cache?key=<string> | Purge one entry. <string> is hashed verbatim, so it must equal the entry's full cache-key value — for the built-in default key that is <host><uri><raw-query-string> (e.g. example.com/blog/post-42?id=1), not just the path. The argument is not URL-decoded: percent escapes become literal key bytes, and a raw & terminates the argument. Use a PURGE request to the cached URL (above) when its reconstructed key cannot be represented verbatim as one query argument. Drops L1 + L2. |
POST /_cache?tag=<name> | Purge every page tagged <name> across L1 + L2. Redis tag enumeration is capped at 128 KiB per request; an index exceeding that bounded legacy reply returns 500 with {"purged":0,"l2":"incomplete"} and retains the index and objects without partial deletion. A later retry can succeed only after the set shrinks or pagination support is added. |
POST /_cache?url=<path[,path,...]> | Warm those paths (background prefetch). Each warm subrequest fetches anonymously — the admin request's Cookie header is stripped, so the entry is stored under the cookieless anonymous key a visitor looks up (and no per-visitor/segment body is pulled from the origin), even if you trigger the warm from a logged-in browser. Fires at most cache_turbo_warm_max subrequests (default 32) regardless of list length. |
POST /_cache?url_file=<path> | Same as ?url=, but the list of paths comes from a file on disk (one path[?query] per line, CRLF tolerated) instead of the query string — useful when the list is longer than comfortably fits in a URL. Requires nginx built with --with-threads and an available/default thread_pool; opening, validating, and reading the file are posted through nginx's thread pool, and a missing prerequisite is a clean 500 with a JSON error body plus an error-log entry. A 60-second watchdog logs that an operation is still running but does not unsafely cancel the thread or release its request; completion retains sole ownership of cleanup. Subject to the same cache_turbo_warm_max cap, plus its own bounded read: the file must be a regular file no larger than 64 KiB, and no single line may exceed 2048 bytes; either limit, or a missing/unreadable file, is a clean 500 with a JSON error body, never a crash or a silent partial warm. A failed thread-pool post or a dynamic thread_pool value that cannot be evaluated for the request produces a separate clean 500 scheduling error rather than being reported as a missing prerequisite. A named pool that does not exist remains a prerequisite error. |
lock_ttl is the one effective-config field on the JSON object — every other
key is a zone counter or gauge. It reports the single-flight lock TTL in seconds
as the runtime actually uses it: after preset-band resolution, after
inheritance, and after the parse-time clamp to 4294967295 (the time_t
ceiling that keeps now + lock_ttl × load_factor from overflowing). That last
part is the reason it exists — cache_turbo_lock_ttl 999999999s is accepted, not
rejected, and reading lock_ttl back is how you see that it is behaving as the
ceiling rather than as the number you wrote.
Two things to know before reading it:
- It is per-location, while the counters are per-zone. The value reported is
the admin location's own effective
lock_ttl, not that of the cached locations sharing the zone. To inspect a particular location's value, setcache_turbo_lock_ttlon the admin location to match it, or give that location its own admin endpoint. - Only the effective value is exposed, not the raw configured one. The clamp
happens at parse time and does not keep the original, so an out-of-range
configured value is not retained anywhere in the running config. If you need
to know what was written, read the config file;
lock_ttltells you what is in force.
Monitoring (Prometheus + Grafana)
The admin endpoint speaks Prometheus. Point a scrape at it:
$ curl 'localhost/_cache?format=prometheus'
# HELP cache_turbo_hits_total Fresh L1 cache hits served.
# TYPE cache_turbo_hits_total counter
cache_turbo_hits_total{zone="ct"} 1240
# TYPE cache_turbo_misses_total counter
cache_turbo_misses_total{zone="ct"} 83
cache_turbo_stale_serves_total{zone="ct"} 12
cache_turbo_refreshes_total{zone="ct"} 11
cache_turbo_evictions_total{zone="ct"} 0
cache_turbo_l2_hits_total{zone="ct"} 61
cache_turbo_l2_misses_total{zone="ct"} 22
cache_turbo_bypasses_total{zone="ct"} 5
cache_turbo_refuse_set_cookie_total{zone="ct"} 3
cache_turbo_refuse_encoded_total{zone="ct"} 0
cache_turbo_refuse_vary_unsafe_total{zone="ct"} 7
cache_turbo_refuse_authorization_total{zone="ct"} 41
cache_turbo_refuse_cache_control_total{zone="ct"} 2
cache_turbo_refuse_require_header_total{zone="ct"} 0
cache_turbo_refuse_partial_total{zone="ct"} 0
cache_turbo_refuse_head_total{zone="ct"} 6
cache_turbo_regen_cost_ms{zone="ct"} 34
cache_turbo_autotuned_beta{zone="ct"} 1700
cache_turbo_autotuned_load{zone="ct"} 1000
Every sample is labelled by zone, so one job can scrape many zones. Metrics:
| Metric | Type | Meaning |
|---|---|---|
cache_turbo_hits_total | counter | Fresh hits served from RAM. |
cache_turbo_misses_total | counter | Requests that fell through to the backend. |
cache_turbo_stale_serves_total | counter | Old copies served during a refresh. |
cache_turbo_refreshes_total | counter | Background refreshes started. |
cache_turbo_evictions_total | counter | Entries dropped under memory pressure (LRU). |
cache_turbo_l2_hits_total | counter | L1 misses the L2 (Redis or memcached) tier satisfied. |
cache_turbo_l2_misses_total | counter | L1 misses L2 couldn't satisfy (went to origin). |
cache_turbo_lock_waits_total | counter | Cold-miss requests that waited on a single-flight winner's fill. |
cache_turbo_min_uses_skips_total | counter | Requests sent to origin (not stored) for being below cache_turbo_min_uses. |
cache_turbo_l2_neg_skips_total | counter | L2 GETs skipped because a cache_turbo_l2_negative_ttl memo already recorded a miss for the key. Each unit is one L2 round-trip not taken. |
cache_turbo_bypasses_total | counter | Requests skipped to origin by a cache_turbo_bypass predicate or a CMS backend preset (a subset of misses). |
cache_turbo_refuse_set_cookie_total | counter | Store refused because the response carried Set-Cookie (RFC 9111 floor). |
cache_turbo_refuse_encoded_total | counter | Store refused because the response was already Content-Encoding'd — origin pre-compression, or a filter-order mistake. A rising count on an otherwise well-behaved origin usually means the origin pre-compresses; see Mixing with nginx's native cache. |
cache_turbo_refuse_vary_unsafe_total | counter | Store refused because the response Vary header named an axis outside the whitelist (Accept-Encoding/User-Agent/Accept-Language/Origin) or *. Does not include Vary: Cookie/Authorization (those are RFC 9111 floors, already visible via refuse_set_cookie_total/refuse_authorization_total's siblings) — a rising count here is the signal to reach for cache_turbo_vary_ignore/cache_turbo_vary_key once available. |
cache_turbo_refuse_authorization_total | counter | Requests refused (lookup and store) for carrying Authorization (RFC 9111 floor). |
cache_turbo_refuse_cache_control_total | counter | Store refused by a Cache-Control/CDN-Cache-Control/Surrogate-Control directive (no-store/no-cache/private/max-age=0/s-maxage=0). |
cache_turbo_refuse_require_header_total | counter | Store refused because cache_turbo_require_header was unmet (header absent, or present without an affirmative value). Zero unless the directive is configured. |
cache_turbo_refuse_partial_total | counter | Store refused because the response was 206 Partial Content (never cached — the key carries no Range). |
cache_turbo_refuse_head_total | counter | Store refused because the request was HEAD (its empty body must never overwrite the stored GET entry). |
cache_turbo_regen_cost_ms | gauge | Average backend regeneration time (ms). |
cache_turbo_autotuned_beta | gauge | Live autotuned beta ×1000 (0 = none). |
| `cache_turbo_autotuned_load$ | \text{gauge} | \text{Live} \text{load} \text{factor} \times 1000 \text{widening} \text{the} \text{stale} \text{window} + $lock_ttl` under load (1000 = baseline / not under load, up to 4000). |
cache_turbo_sie_serves_total | counter | Responses served from a stale-if-error snapshot. |
cache_turbo_breaker_serves_total | counter | Responses served from the circuit breaker's armed fallback while OPEN. |
cache_turbo_origin_failures_total | counter | Origin responses recorded as a failure by the circuit breaker. |
cache_turbo_breaker_opens_total | counter | Lifetime count of CLOSED->OPEN circuit breaker trips. |
cache_turbo_breaker_state | gauge | Circuit breaker state (0=closed, 1=open, 2=half-open). |
cache_turbo_used_bytes | gauge | Slab bytes currently charged for cached payload and metadata in this zone. Excludes fixed per-zone init overhead (the zone's own control block and the W-TinyLFU sketch), which is allocated once and never freed. This is the demand side of the zone's memory: the gap between it and the zone's configured size is slab fragmentation plus allocator bin rounding, so watching the two together tells you whether a zone that is evicting is genuinely full or merely fragmented. |
prometheus.yml:
scrape_configs:
- job_name: cache_turbo
metrics_path: /_cache
params:
format: [prometheus]
static_configs:
- targets: ['nginx-host:80']
Same
allow/denygate applies — let your Prometheus box reach it, keep the public out.
A ready-made Grafana dashboard is in
contrib/grafana-dashboard.json — import it and
pick your Prometheus datasource (hit ratios, L1/L2 request rates, regen cost,
autotuned beta, per-zone template variable).
Useful PromQL: hit ratio
rate(cache_turbo_hits_total[5m]) / (rate(cache_turbo_hits_total[5m]) + rate(cache_turbo_misses_total[5m])), backend regen rate
rate(cache_turbo_refreshes_total[5m]), plus cache_turbo_regen_cost_ms and
cache_turbo_autotuned_beta as plain gauges.
Eviction pressure alert
The cache_turbo_evictions_total counter rises when entries are dropped to stay under the zone's memory ceiling — a sign of LRU pressure under load. A zone that is too small for the working set will evict entries faster than they are used, thrashing the cache and defeating compression gains.
Prometheus alert rule (adapt thresholds to your workload):
- alert: CacheTurboEvictionThrashing
expr: rate(cache_turbo_evictions_total[5m]) > 100
for: 5m
annotations:
summary: "Zone {{ $labels.zone }} evicting {{ $value | humanize }} entries/sec"
description: |
High eviction rate indicates the zone is sized too small for the working
set. Increase cache_turbo_zone size, enable Redis L2 to warm cold hits,
or reduce the requested TTLs to lower the retention burden.
Interpretation:
- Eviction rate near 0 — healthy; entries live their full TTL and age out naturally.
- Eviction rate creeping up — the working set is growing relative to zone capacity. Monitor and plan for expansion.
- Eviction rate sustained and high — zone is under severe pressure. LRU eviction is too aggressive; legitimate hot entries are being dropped.
The eviction counter is cumulative; in Prometheus use rate() over a 5-minute window to surface the per-second eviction rate. Pair this with hit ratio (rate(cache_turbo_hits_total[5m]) / (rate(cache_turbo_hits_total[5m]) + rate(cache_turbo_misses_total[5m]))) — a coinciding hit-ratio drop + eviction spike confirms the zone needs more memory.
Redis L2 (shared cache)
By default the cache lives in each box's RAM (L1). Add Redis as a shared L2
so a whole fleet of nginx boxes share one cache: write-through on store, and one
GET on an L1 miss (L1 hits never touch Redis). Point it with a DSN:
# plain
cache_turbo_redis redis://10.0.0.5:6379/0 keepalive=32 keepalive_timeout=60s;
# with ACL user + password + db 2
cache_turbo_redis redis://cache:s3cret@10.0.0.5:6379/2 keepalive=32 keepalive_timeout=60s;
# TLS (rediss://) — verifies the server cert against the system CA by default
cache_turbo_redis rediss://redis.internal:6380/0 keepalive=32 keepalive_timeout=60s;
# TLS with a private CA, and override the verified name
cache_turbo_redis rediss://10.0.0.5:6380/0 tls_ca=/etc/ssl/redis-ca.pem tls_name=redis.internal
keepalive=32 keepalive_timeout=60s;
The driver pipelines AUTH (+ ACL user) and SELECT <db> before each command;
rediss:// wraps the socket in TLS. Any DSN field can also be given as a
trailing option, which overrides the DSN:
| Option | Default | Meaning |
|---|---|---|
password= | — | AUTH password (or put it in the DSN userinfo). |
user= | — | ACL username (Redis 6+). |
db= | 0 | SELECT this db number. Must be 0–15, matching Redis's default databases 16; a larger index is rejected at config time rather than failing every L2 op at runtime. Same bound applies to the /N suffix of a DSN. |
tls=on|off | from scheme | Force TLS on/off regardless of redis:///rediss://. |
tls_verify=on|off | on | Verify the server cert + hostname. Leave on unless you know why. |
tls_ca=<file> | system CAs | CA bundle to trust (for a private CA). If given, must be non-empty -- an empty tls_ca= is rejected at config time rather than silently falling back to the system CA store. |
tls_name=<host> | DSN host | Name used for SNI + cert verification. If given, must be non-empty -- an empty tls_name= is rejected at config time rather than silently falling back to the DSN host. |
prefix= | ct: | Key prefix in Redis. Must be non-empty, printable ASCII with no spaces or control characters, and at most 186 bytes (the module appends up to 64 bytes of key, and an L2 key may not exceed 250). Rejected at config time on both L2 backends. |
timeout= | 250ms | Connect/read timeout. |
keepalive=N | 0 (off) | Idle connections pooled per worker for reuse. A pooled conn is reused only within the same db/credentials/TLS context. 0 opens a fresh connection per op. Sized per connection profile — see the note below. |
keepalive_timeout= | 60s | How long an idle pooled connection is kept before it is closed. Per connection profile — see the note below. |
keepalive=andkeepalive_timeout=are honoured per connection profile. Each distinct backend profile — host/port, db, credentials, and TLS context — gets its own idle-connection pool in each worker, with its ownkeepalive=size andkeepalive_timeout=, taken from the location that opens that profile. Locations that share a profile share (and reuse) its pool, which is normal and desirable; incompatible profiles are fully isolated, each with its own budget, so distinct backends cannot starve each other and no connection can leak between them. A location withkeepalive=0simply opens a fresh connection per op.There is a soft cap of 16 distinct pooled profiles per worker; realistic deployments use one to three. A configuration that exceeds it runs the extra profiles unpooled (a fresh connection per op) rather than failing — safe, since L2 is advisory.
TLS needs nginx built with
--with-http_ssl_module(the stocknginxpackage is). Without it, arediss:///tls=onconfig is rejected at start. Passwords sit in your nginx config — keep itchmod 600/ out of git.
What keepalive= buys — and what is not established
The default is 0 (off), which opens and closes a Redis connection for
every L1 miss that consults L2. Whether turning the pool on shows up as more
requests per second depends entirely on whether your L2 path is what limits the
workload.
- It always removes connection churn. Measured over a 9-point sweep with 3
interleaved repeats, Redis-side
total_connections_receivedper run fell from roughly 21-27k withkeepalive=0to roughly 6-12k atkeepalive=32-64, with theGETcount flat. The reduction is monotone and reproducible. On a busy fleet that is meaningfully less accept/close work and fewer sockets inTIME_WAITon the Redis host. - The churn it removes is real work, not just tidiness. With
keepalive=0the connection count tracks the L2GETcount almost exactly (measured ratio 1.003) — one full TCP connect plus oneTIME_WAITslot per L2 lookup. In a profile of that configuration the kernel connect path (__inet_hash_connect,__inet_check_established,tcp_twsk_unique) is the largest single block of miss-path kernel time; enabling the pool removes those symbols from the profile entirely. So the cost being eliminated is a genuine per-miss cost on the request path, not merely bookkeeping on the Redis host. - Whether that converts to throughput depends on your bottleneck, and we have
not measured the conversion cleanly. On a rig whose miss path was limited by
the origin rather than by L2, sweeping
keepalive=moved churn monotonically while the request-rate ranges overlapped completely — a cost that only bites above a cap cannot show up in a workload already capped below it. On a rig where L2 was hot, throughput moved by a large factor, but that arm exercised the L2 path about 11x harder and so is not a like-for-like measurement of the knob alone. Treat the size of any throughput gain as unquantified. What is established is the mechanism, not the magnitude.
Practically: set keepalive= if your deployment takes L2 lookups at any rate —
you are removing a per-lookup connect either way. Do not budget a specific
requests-per-second improvement from it, and do not raise the pool size chasing
one. Pools are per worker and per connection profile, so the connections a
box can hold open is workers x profiles x keepalive; size that product against
the Redis server's maxclients. The per-profile accounting is detailed in the
per-profile options note above.
memcached L2 (alternative backend)
If you already run memcached as your shared cache, point the L2 tier at it
instead of Redis with cache_turbo_memcached — same write-through-on-store /
sync-GET-on-L1-miss model, native client (no libmemcached):
cache_turbo_memcached 127.0.0.1:11211 prefix=mc: timeout=250ms
keepalive=16 keepalive_timeout=60s;
keepalive=N pools up to N idle connections per worker and reuses them for
later ops instead of opening a fresh TCP connection each time (0, the default,
opens one per op). keepalive_timeout= (default 60s) is how long an idle
pooled connection is kept before it is closed. memcached has no db, credentials
or TLS, so a pool is keyed only by peer address — every location pointing at
the same HOST:PORT shares its worker's pool, which is allocated at the size the
location that first touched it configured. A location with keepalive=0 neither
borrows from nor re-pools into that shared pool: it always dials a fresh
connection, exactly as documented, and cannot drain a neighbouring location's
pool. A connection is returned to the
pool only after its reply was read to a clean protocol boundary; a timeout, a
short/fragmented reply, an EOF or a server error closes the connection instead
of pooling it, so a reused connection is never mid-reply. The same soft cap of
16 pooled profiles per worker as the Redis tier applies.
memcached is deliberately the lean L2: it has no sorted sets, no SCAN, and
no atomic SET-NX, so the features that need those are unavailable on it —
tag purge (cache_turbo_tag, POST /_cache?tag=), whole-keyspace
purge (POST /_cache?all=1 clears L1 only), and the cross-node
single-flight lock (per-box single-flight still works). Single-key purge,
write-through, cross-instance fill and stale-while-revalidate all work as with
Redis. Values at/above memcached's 1 MiB item ceiling are skipped (the page
stays L1-only). Use Redis if you need tags or cluster-wide dogpile protection;
use memcached if you already run one and want a simple shared object tier.
cache_turbo_redis and cache_turbo_memcached are mutually exclusive in the
same block.
Building & the stack
It's a normal dynamic module:
./configure --with-threads --add-dynamic-module=/path/to/nginx-cache-turbo-module
make modules
No external libraries — the Redis client is hand-rolled on nginx's own event loop. Builds against nginx and Angie.
Prebuilt, in the deb.myguard.nl nginx/angie stack. Rather than build it yourself, install the packaged module from the deb.myguard.nl APT repository — it's shipped and kept current alongside the hardened HTTP/3 nginx / Angie builds and their full dynamic-module set:
# nginx
$ apt install libnginx-mod-http-cache-turbo
# Angie
$ apt install angie-module-http-cache-turbo
- Module catalogues: nginx modules · Angie modules (optimized & extended)
- Directive synopsis: modules-synopsis / http-cache-turbo
- Writeup: nginx-cache-turbo — a built-in page cache
Benchmarking
tools/bench.sh measures throughput/latency and compares
cache-turbo against the alternatives. It stands up an origin plus four edges on
separate ports — A origin direct (the floor), B nginx proxy_cache,
C cache_turbo L1 shm, D cache_turbo + L2 Redis — primes each so the run
hits the cache (not the origin), then drives wrk --latency and prints an
rps / p50 / p99 / hit-ratio table. The hit-ratio column comes from the module's
own Prometheus counters, so a "fast" run that secretly missed shows up as
< 100 % instead of as a bogus number.
$ eval "$(ci/tools/ci-build.sh nginx 1.31.1 nginx)" # stock-O release: exports binary= module=
$ MODULE="$module" ci/tools/bench.sh "$binary" 15 8 # 15s/run, 8 conns
$ SIZES="tiny medium large" REDIS="redis://127.0.0.1:6379/0" \
MODULE="$module" ci/tools/bench.sh "$binary" 15 8 # all sizes + the L2-Redis run
Build the nginx binary as a release build (stock
-O, no-fsanitize, no valgrind) — sanitizers slow serving 10–50× and measure nothing real. That istools/soak.sh's job: it proves the module survives heavy churn under ASAN/valgrind; bench.sh proves how fast it serves.
Full method, the reference environment, a result set (cache-turbo +23–37 %
over nginx's own proxy_cache on small/medium bodies, ~10 % on multi-megabyte
bodies), and the caveats are in BENCHMARK.md.
License
BSD-2-Clause — the same license as nginx and Angie, the servers this module is built to load into. See LICENSE.