nginx-cache-turbo-module

August 31, 2026 · View on GitHub

Lint Build&Test Security Scanners Fuzzing CodeQL A/UBSan CI Deep Valgrind Testkit Testkit Valgrind

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.

WorkflowWhat it gates
Lintthe 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&Testvalidation (shellcheck, ruff, sync-stamp, port bands), build, the Test::Nginx preset suite, and the runtime driver
Security Scannersflawfinder >=4, tracked-policy clang-tidy, advisory GCC -fanalyzer, and semgrep >=WARNING over src/
Fuzzingreplay of every recorded regression, then a time-boxed run of the five libFuzzer targets
CodeQLCodeQL over the module TU; keeps its own monthly schedule:
A/UBSanASan+UBSan single-process and multi-worker soaks, static --add-module build
CI Deepmonthly: long fuzz campaigns, memcheck soak, the nginx/angie compatibility matrix
Valgrindon-demand 60s memcheck soak; the monthly deep workflow carries the scheduled 600s soak
Testkitthe 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 Valgrindweekly: 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 versionsweekly 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

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 (one GET) 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-Cache and X-Cache-Status before storing, so an L1 hit never replays a frozen age/status. cache-turbo's own X-Cache: HIT/STALE is the source of truth; read proxy_cache's state via $upstream_cache_status if you want it.
  • Layered staleness. A cache-turbo SWR refresh goes through proxy_cache, which may serve its stale. If that matters, keep proxy_cache TTL ≤ 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_cache for 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-turbonginx proxy_cache
Store / phaseshared memory, ACCESS phasedisk, content phase
Throughput+23–37 % small/medium (bench)baseline
Stale-while-revalidate + stale-if-erroron by defaultmanual (proxy_cache_use_stale)
Dogpile / single-flightper-box and cross-fleet (Redis lock)per-box (proxy_cache_lock)
Shared / distributed cacheRedis/memcached L2 across the fleetper-box disk, every node cold alone
One config for php-fpm and APIssame directives (fastcgi_pass + proxy_pass)separate fastcgi_cache / proxy_cache
Tag purge · auto-Vary · CMS auto-classify · Prometheusbuilt innone
Range requests on a HIThonoured (Range: answered 206, same as a MISS)honoured
Survives reload / restartreload yes, restart no — shm cleared on restart (Redis L2 softens)yes, persists on disk
Capacitybounded by RAMhuge on-disk corpus
Built into nginxno — dynamic moduleyes, nothing to install
Maturitynewera 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 a proxy_cache disk tier (above).

Not a differentiator: caching dynamic / php-fpm / API responses — fastcgi_cache / proxy_cache do 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:

PriorityHeaderEmitted byTTL token
1 (highest)Surrogate-ControlFastly, Akamaimax-age
2CDN-Cache-ControlCloudflare (RFC 9213)s-maxage > max-age
3Cache-Controleveryones-maxage > max-age
4 (lowest)Expireslegacyabsolute 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-store veto — a targeted no-store/private/max-age=0 refuses the shared store the same way a plain Cache-Control: no-store does. 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.
  • ignore modecache_turbo_cache_control ignore discards the targeted variants too, alongside Cache-Control.

See also: cache_turbo_cache_control for the full honor/ respect/ignore semantics, and Mixing with nginx's native cache for stacking with proxy_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-Key the 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 on the emitted Surrogate-Key is kept out of the stored blob and regenerated live from cache_turbo_tag on each serve — so a HIT emits exactly one header line and always the current tag set. With the directive off, an origin-supplied Surrogate-Key is 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-), not curl -sI. A HEAD response is never stored, so -I can never show you a HIT no 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_valid replaces, it does not merge. If a nested location sets any cache_turbo_valid of 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's 301/404 TTLs 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 Authorization header → 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 no Range, 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-Encoding set before our header filter, i.e. the upstream itself compressed it) → not cached by default. Set cache_turbo_key_encoded_origin on to 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 exact gzip, br, or zstd Content-Encoding from 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 Age and 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_vary and add the matching cache_turbo_normalize_vary bucket (e.g. encoding) for an axis the origin already lists in Vary, 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_vary when you know the axis up front, auto_vary to 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-Encoding is 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 (the proxy_cache model). So one stored copy already serves every encoding correctly, and auto_vary no longer partitions on Accept-Encoding when the response it captured is unencoded: gzip, br, zstd and 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 explicit cache_turbo_key_encoded_origin on opt-in stores supported encoding classes separately and retains the serve-side Accept-Encoding guard; auto_vary remains the safety net for response-driven variation.)

Don't turn cache_turbo_auto_vary off on a varied origin. It is on by default precisely because, with it off, the cache keys on the request, not on the response's Vary, and there is no safety net: a Vary:-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 for Vary: Cookie/Authorization a privacy leak across users on the stale-serve path. Leave auto-Vary on unless you fold the axis into the key yourself with cache_turbo_normalize_vary (below) or an explicit cache_turbo_key. (There is no cache_turbo_vary_safe refuse-to-store knob — auto_vary is 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 Vary line 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 echoes Vary: Accept for 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 mode auto_vary's refusal exists to prevent (RFC 9110 §12.5.5).

cache_turbo_vary_ignore can never be used to ignore *, Cookie or Authorization — 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 / auto union, and both spellings are a config error. They used to mean wordpress + woocommerce + joomla. That was never a safe default:

  • it never covered every backend — it named 3 of the 34 presets that now exist, so auto on a Drupal site silently enabled no Drupal rules;
  • its woocommerce shipped without implying wordpress, leaving /wp-admin/ cacheable (see woocommerce.md);
  • the joomla in it shipped no cookie rule at all, so auto on 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:

  1. Nothing stored under credentials, ever. The store floor is untouched and ungated by any directive: a response produced for a request that carried Authorization is 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.
  2. 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=…, or must-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 public representation really is identical for every principal. If the origin returns per-user data on a URL while still marking it public, 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 specific location that 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.

PresetURI prefixesQuery argsCookie header substrings
wordpress/wp-admin/, /wp-login.php, /wp-cron.php, /xmlrpc.php, /wp-json/preview, rest_routewordpress_logged_in_, wp-postpass_, comment_author_
woocommerce/cart, /checkout, /my-accountwc-ajaxwoocommerce_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_xfTokenxf_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_actionsapi_key, api_username_t=
phpbb/ucp.php, /mcp.php, /adm/, /posting.php, /memberlist.php, /search.php, /report.phpsid(value) …_u != 1
drupal/user, /admin, /node/add, /system/, /core/install.php, /jsonapi, /oauthSESS ¥
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, giftghost-members-ssr, ghost-admin-api-session
wagtail † §/admin/, /django-admin/, /documents/sessionid
kirby † §/panelkirby_session
typo3 † ※/typo3fe_typo_user, be_typo_user
invision/admin, /login, /register, /lostpassword, /messengerdo=compose, do=post, do=reply, do=report, module=messaging_loggedIn (suffix); (key) ips4_hasJS, ips4_theme, ips4_language
smfaction=admin, action=login, action=login2, action=logintfa, action=logout, action=profile, action=pm, action=post, action=post2, action=moderate, action=reporttm, action=xmlhttpSMFCookie (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.phpforum_cookie, punbb_cookie (presence-only)
phorumadmin.php, login.php, register.php, pm.php, posting.php, post.php, moderation.php, control.php, ajax.php, report.php, follow.phpphorum_session_v5, phorum_session_st, phorum_admin_session_v5; (key) list_style
yabbaction=post, action=post2, action=login, action=login2, action=register, action=register2, action=admin, action=pm, action=imsend, action=imsend2Y2User-, Y2Pass-, Y2Sess- (prefix)
mybb/member.php, /usercp.php, /private.php, /modcp.php, /newthread.php, /newreply.php, /editpost.php, /polls.php, /admin/, /xmlhttp.phpaction=login, action=do_login, action=logout, action=do_logout, action=register, action=do_register, action=activate, action=lostpw, action=do_lostpw, action=resetpassworduser (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/textpatterntxp_login_public, txp_login
bludit/admin, /install.phpBLUDIT-KEY (also __Secure-), BLUDITREMEMBERUSERNAME, BLUDITREMEMBERTOKEN
spip/ecrireaction, var_mode_session, _admin, _lang, _lang_ecrire, _accepte_ajax (suffix, non-empty)
bugzillaaccount/login, mutation, admin/edit and API CGI entry pointsBugzilla_api_key, api_key, Bugzilla_api_token, Bugzilla_token, Bugzilla_login, Bugzilla_password, Bugzilla_login_token, tokenBugzilla_login, Bugzilla_logincookie
mantisbtlogin/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/umbracoUMB_UCONTEXT, back-office token/preview/XSRF cookies, UMB_SESSION, .AspNetCore.Identity.Application
dotclear/admin, /preview, /pagespreviewdcxd, dc_admin, dc_passwd
wikijsadmin/editor/history/source/upload, login/identity and GraphQL routesjwt, connect.sid, loginRedirect
redmine/admin, /my, /login, /logout, /account, /settings, /enumerations, /roles, /trackers, /custom_fields, /auth_sources, /mail_handlerkey_redmine_session, autologin
flarum/admin, /api, /login, /logout, /global-logout, /register, /reset, /confirm, /settings, /notificationsflarum_remember onlyflarum_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:

PresetWhat it can and cannot seeWhat you must do
joomlajoomla_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".
phpbbHandled by a cookie VALUE predicate (∆). _u/_k/_sid are set for guests too (an anonymous visitor gets _u=1ANONYMOUS), 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:

PresetThe cookie stops meaning "logged in" when…Consequence
wagtailthe 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=Trueevery guest gets sessionidbypassed → hit rate 0
kirbya 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: joomla has 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 matching cache_turbo_no_store. phpBB's built-in _u != 1 value predicate does protect members even though all visitors receive the cookie. A bypass alone skips only lookup and still stores. woocommerce implies wordpress automatically (so /wp-admin/ is covered without stacking); drupal + mediawiki lean on the origin's own Cache-Control: private, so don't set cache_turbo_cache_control ignore on those; woocommerce's /cart, /checkout, /my-account prefixes are English defaults that match nothing on a translated store (WooCommerce creates the pages in the site locale), so add your own cache_turbo_bypass_uri there and rely on the stacked wordpress cookie rules; and typo3's cookie name is admin-overridable (FE/cookieName) — if you change it, add your own cache_turbo_bypass and cache_turbo_no_store, or logged-in pages get cached (this one fails unsafe, not safe — see typo3.md).

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 / argTreatmentWhy
xf_user, xf_session_admin, xf_sessionbypassthe 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_inbypassLiteSpeed addon's true login-only cookie (present only if you run it).
_xfToken (query arg)bypassXF's per-session CSRF token on some GET links (logout, style switcher).
xf_style_id, xf_style_variation, xf_language_idvalue-keyedpresentation 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_consentignoredchanges 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 own cache_turbo_bypass $cookie_<prefix>session $cookie_<prefix>session_admin $cookie_<prefix>user; and the same list on cache_turbo_no_store (the bypass skips only the lookup and still stores). All three: session is the only cookie an ordinary (non-remember-me) login carries, session_admin the admin session, user remember-me. Bypassing user alone leaves ordinary and admin logins cacheable — the leak this preset exists to prevent.
  • The REST API (/api/) authenticates on the XF-Api-Key header, 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 on r->uri from the site root and its prefixes are anchored at byte 0, so /forums/login does not match /login and the board gets zero URI-rule coverage — the admin surface included. Scoping the location does not help (it routes requests, it does not rewrite r->uri): declare the mount with cache_turbo_backend_prefix /forums/; (see the vhost example in docs/xenforo.md).

Interactions and safety

  • Implies cache_turbo_cache_control honor (unless you set it explicitly). So if your CMS plugin already emits Cache-Control: no-cache on a page it knows is dynamic, that page self-excludes at store time too — belt and braces. Pin a fixed TTL instead with an explicit cache_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_store overrides, and the universal safety rules still apply on top of it regardless of preset: a response with Set-Cookie, or a request carrying Authorization, 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 own cache_turbo_bypass $cookie_yoursession; plus cache_turbo_no_store $cookie_yoursession; — a bypass skips only the lookup, so without the no_store half 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 responses private).

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 URIr->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 $scheme and 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 one cache_turbo zone. 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 HTTPS server blocks separate cache_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_args strips sid/sessionid/ref and 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 it private/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 paramsworker CPU per request
320.003 ms
64 (default cap)0.011 ms
1280.041 ms
10002.06 ms
400023 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
Knobmicroconservativebalanced (default)aggressive
fresh TTL (valid)1s30s60s300s
`beta$ (\text{refresh} \text{eagerness} \times 1000)100050010003000
lockttllock_ttl1\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)1112

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 micro preset keeps the window tight. It is valid 1s + lock_ttl 1s + a ×2 stale multiplier, so a copy is served stale for ≤1s more (≤2s old). Using balanced/aggressive instead widens that (×4/×8 = up to 4s/8s stale) — usually not what you want for "near-real-time". To microcache at a different TTL, keep cache_turbo_preset micro and add an explicit cache_turbo_valid 2s; (the explicit knob wins; the ×2 stale window scales with it).
  • cache_control mode vs a fixed TTL. A CMS preset (cache_turbo_backend) defaults cache_turbo_cache_control to honor, so an app that emits Cache-Control: max-age=600 would override your 1s. Set cache_turbo_cache_control respect; to pin the microcache TTL regardless of app headers (example B). For an API that you want to honour its own Cache-Control, leave it on honor and drop the static valid.
  • Per-user safety. Anything with an Authorization request header, or a response with Set-Cookie / Cache-Control: private, is never cached or served from cache. For cookie-session apps add an explicit cache_turbo_bypass/cache_turbo_no_store on 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_page note 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-error window is by operator consent. That is why cache_turbo_keep_stale defaults to 24h, not forever: serving a day-old page is the right answer for most sites during an outage, but you should still say cache_turbo_keep_stale off if serving stale data is ever wrong for a given location (a bank balance, for example) and pick your own window with cache_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_uses does, 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_ttl is 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 + one ngx_chain_t per 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_pct of 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 fieldPrometheus
admission_refusedcache_turbo_admission_refused_total
sketch_bumpscache_turbo_sketch_bumps_total
sketch_gencache_turbo_sketch_gen
used_bytescache_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 tunesUnder loadBounded byTouches 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 windowno — 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 — at the moderate-load threshold, rising with a slower origin, hard-capped at — 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 with allow/deny (or auth). Never public. Both warm sources are capped at cache_turbo_warm_max URLs per request (default 32); ?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, the location-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

PairRelationshipWhat happens
cache_turbo_rediscache_turbo_memcachedhard errorOne L2 per block. Declaring both in the same block fails the config at start ("the two are mutually exclusive").
cache_turbo_tagcache_turbo_redisrequired for purge-by-tag onlyThe 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_varycache_turbo_normalize_varydon't double-cover an axisNot 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_presetcache_turbo_valid/_beta/_lock_ttlexplicit winsThe preset sets a band of defaults; any explicit knob overrides just that knob (the rest stay at the preset). Not exclusive.
cache_turbo_backendcache_turbo_cache_controlimpliesEnabling 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 / SWRdisablesInline regeneration replaces serve-stale-while-revalidate; a stale entry is no longer served during refresh, and stale-if-error no longer applies.

Directive synopsis

DirectiveContextDefaultWhat it does
cache_turbo_zone name=NAME SIZEhttpDeclare a shared-memory cache zone (min 8 pages).
cache_turbo NAME / offserver, locationoffTurn 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, locationAuto-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: classicpresswordpress, backdropdrupal; 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 onserver, locationoffMake $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 STRINGserver, locationrawWhat 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 NAMEserver, locationbalancedmicro / 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...] TIMEserver, locationpreset (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 Nserver, locationpreset (1000)Refresh eagerness, ×1000 (1000 = 1.0). Higher = refresh sooner/more often.
cache_turbo_lock_ttl TIMEserver, locationpreset (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 Nserver, locationpreset (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 / offserver, locationonCold-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|offserver, locationoffMark 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|offserver, locationoffPermit requests carrying Authorization to read shared entries; storage remains subject to the authorization safety floor.
cache_turbo_store_head on|offserver, locationoffThe 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 TIMEserver, location5sHow 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 Nserver, locationpreset (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 TIMEserver, location0 (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, locationon 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 Nserver, location0 (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 SIZEserver, location1mMaximum 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, locationIf 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, locationIf 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, locationSkip 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, locationAllow 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 PATHserver, locationDeclare 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, locationFold 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, locationName 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 NAMEserver, locationInverts 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 onserver, locationoffAllow 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|ignoreserver, locationrespectHow 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 / offserver, locationonThe 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 Nserver, location0 (unlimited)Cap concurrent background refresh subrequests per shared zone; 0 leaves the cap unlimited.
cache_turbo_keep_stale off | <time> | foreverserver, location24hOrigin-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_stalemust-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, locationevery 5xxWhich 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 itcache_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 | offserver, locationonTurn 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 Nserver, location5Consecutive 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|offserver, locationoffWhen 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 TIMEserver, location10sThe 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 TIMEserver, location30sHow long an OPEN breaker lasts before promoting exactly one request to probe the origin. 0 is a hard config error, not a disableopen_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 TIMEserver, locationcache_turbo_breaker_open's effective valueAdvisory 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 onserver, locationoffAdapt 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, locationAdd 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, locationAdd 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 EXPRserver, locationTag stored pages (whitespace/comma list) so they can be purged as a group. Needs cache_turbo_redis.
cache_turbo_surrogate_key on|offserver, locationoffEmit 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 NAMElocationMake this location a control endpoint for zone NAME (stats/purge/warm). Gate with allow/deny.
cache_turbo_warm_max Nserver, location32Ceiling 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, locationExtra 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 Nserver, location64Cap 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, locationoffAppend a variant bucket to $cache_turbo_normalized_args: encoding (br/gzip/identity) and/or device (mobile/desktop).
cache_turbo_auto_vary on|offserver, locationonRead 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/Authorizationor 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 TIMEserver, location2sRevalidate an auto-Vary marker through L2 at most once per interval; 0 restores unconditional local-marker trust.
cache_turbo_vary_ignore HEADER...server, locationoff (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 a location (or server) setting. Two locations on one zone therefore accumulate into one counter pair, while each contributes its own thresholds.

cache_turbo_breaker_open is 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-shared breaker_opened_at. So whether an OPEN breaker is ready to promote a probe is judged using whichever location's breaker_open the current request landed on — not a merge of the two, not the value in force when it tripped. Give two locations on one zone 30s and 5m and the effective reopen delay flaps with your traffic mix. cache_turbo_breaker_retry_after inherits the same property, since it defaults to the effective breaker_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 zone

It 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 off contributes no policy and triggers no warning.

Enforcement scope is not observation scope. cache_turbo_breaker off on 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 reports breaker_state and breaker_opens for 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 (default 5) within the rolling cache_turbo_breaker_window (default 10s), 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

VariableValue
$cache_turbo_normalized_argsThe 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_active1 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_betaThe effective refresh beta ×1000 in force for this request (preset/explicit/autotuned). Handy for debugging/logging.
$cache_turbo_statusThe 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_reasonThe 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

RequestEffect
GET /_cacheJSON stats. Read-only: legacy ?autotune=1 is ignored on safe methods.
POST /_cache?action=autotune&value=1Force an immediate autotune recompute, then return the same JSON stats body a GET /_cache would.
GET /_cache?format=prometheusSame stats in Prometheus text format — scrape this.
POST /_cache?all=1Purge 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, set cache_turbo_lock_ttl on 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_ttl tells 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:

MetricTypeMeaning
cache_turbo_hits_totalcounterFresh hits served from RAM.
cache_turbo_misses_totalcounterRequests that fell through to the backend.
cache_turbo_stale_serves_totalcounterOld copies served during a refresh.
cache_turbo_refreshes_totalcounterBackground refreshes started.
cache_turbo_evictions_totalcounterEntries dropped under memory pressure (LRU).
cache_turbo_l2_hits_totalcounterL1 misses the L2 (Redis or memcached) tier satisfied.
cache_turbo_l2_misses_totalcounterL1 misses L2 couldn't satisfy (went to origin).
cache_turbo_lock_waits_totalcounterCold-miss requests that waited on a single-flight winner's fill.
cache_turbo_min_uses_skips_totalcounterRequests sent to origin (not stored) for being below cache_turbo_min_uses.
cache_turbo_l2_neg_skips_totalcounterL2 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_totalcounterRequests skipped to origin by a cache_turbo_bypass predicate or a CMS backend preset (a subset of misses).
cache_turbo_refuse_set_cookie_totalcounterStore refused because the response carried Set-Cookie (RFC 9111 floor).
cache_turbo_refuse_encoded_totalcounterStore 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_totalcounterStore 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_totalcounterRequests refused (lookup and store) for carrying Authorization (RFC 9111 floor).
cache_turbo_refuse_cache_control_totalcounterStore 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_totalcounterStore 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_totalcounterStore refused because the response was 206 Partial Content (never cached — the key carries no Range).
cache_turbo_refuse_head_totalcounterStore refused because the request was HEAD (its empty body must never overwrite the stored GET entry).
cache_turbo_regen_cost_msgaugeAverage backend regeneration time (ms).
cache_turbo_autotuned_betagaugeLive 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_totalcounterResponses served from a stale-if-error snapshot.
cache_turbo_breaker_serves_totalcounterResponses served from the circuit breaker's armed fallback while OPEN.
cache_turbo_origin_failures_totalcounterOrigin responses recorded as a failure by the circuit breaker.
cache_turbo_breaker_opens_totalcounterLifetime count of CLOSED->OPEN circuit breaker trips.
cache_turbo_breaker_stategaugeCircuit breaker state (0=closed, 1=open, 2=half-open).
cache_turbo_used_bytesgaugeSlab 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/deny gate 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:

OptionDefaultMeaning
password=AUTH password (or put it in the DSN userinfo).
user=ACL username (Redis 6+).
db=0SELECT this db number. Must be 015, 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|offfrom schemeForce TLS on/off regardless of redis:///rediss://.
tls_verify=on|offonVerify the server cert + hostname. Leave on unless you know why.
tls_ca=<file>system CAsCA 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 hostName 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=250msConnect/read timeout.
keepalive=N0 (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=60sHow long an idle pooled connection is kept before it is closed. Per connection profile — see the note below.

keepalive= and keepalive_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 own keepalive= size and keepalive_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 with keepalive=0 simply 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 stock nginx package is). Without it, a rediss:// / tls=on config is rejected at start. Passwords sit in your nginx config — keep it chmod 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_received per run fell from roughly 21-27k with keepalive=0 to roughly 6-12k at keepalive=32-64, with the GET count flat. The reduction is monotone and reproducible. On a busy fleet that is meaningfully less accept/close work and fewer sockets in TIME_WAIT on the Redis host.
  • The churn it removes is real work, not just tidiness. With keepalive=0 the connection count tracks the L2 GET count almost exactly (measured ratio 1.003) — one full TCP connect plus one TIME_WAIT slot 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

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 is tools/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.