Origin Protection
A cache only protects the origin if requests can't get past it. These settings stop made-up URLs and floods of requests from reaching Nuxt and php. They belong to the template's Caddyfile and Helm chart, not to the module or the bundle.
Caching shows where these sit among the other layers. Every setting is also listed in CI/CD Variables → Origin protection, and the compose names are in Docker → Origin protection settings.
Query Parameters on Pages
The query string is part of the cache key, so without help every new query string is a cache miss. /?x=1, /?x=2 and so on would each cost a server-side render, and one client could get past the cache entirely by making up values.
The template's Caddyfile keeps only the query parameters that pages read, and drops every other one before the cache sees the request. /?x=123 is served the cached entry for /, and /blog?page=2&x=9 reaches the cache and Nuxt as /blog?page=2. The browser's address bar keeps the full URL.
The list is the CACHE_QUERY_ALLOWLIST variable on the php container. Its default is what the module's pages read:
CACHE_QUERY_ALLOWLIST="page search order"
- It's a space-separated list of names. A name also keeps its bracketed forms, so
orderkeepsorder[title]. *turns the allowlist off. Wildcards aren't supported.- It only runs on pages that can be cached: paths with no file extension, with no auth cookie or
Authorizationheader. It never touches/_api,/_nuxt,/__*,/.well-known,/uploads,/bundles,/_cwa,/user-areaor the login, password and email-verification pages, which read their own parameters such as?redirect=. /_apikeeps every parameter, because each API resource declares its ownparameters:and collections take the page query. The rate limit protects the API instead.
cwa_force to the list. Only the admin uses the parameter, and requests with a login cookie skip the allowlist, so admins still get it. Kept for anonymous requests, every made-up value would be a cache miss that reaches Nuxt, and on @cwa/nuxt2.0.0-alpha.3 and earlier a value other than true sent the server render into an endless redirect (the module 2.0.0-alpha.4 release notes). If you set CACHE_QUERY_ALLOWLIST yourself, check that cwa_force isn't in it.page, search or order[...], for example a filter you bind with useQueryBoundModel('category'), server-side rendering never sees it. Set CACHE_QUERY_ALLOWLIST="page search order category".Set it in .env or the shell for compose, or as the CI variable CWA_API_CACHE_QUERY_ALLOWLIST for a Kubernetes deploy. See CI/CD Variables → Origin protection.
The allowlist is the template's own Caddy handler, query_allowlist (api/frankenphp/caddy/queryallowlist), because Caddy's uri query can only delete parameters it's told the names of. It removes whole pairs and leaves the rest of the query byte for byte, so it never re-encodes it the way uri query does. The php image must be rebuilt to include it: see Troubleshooting → Caddy and Souin.
@page_query matcher's path exclusions in step with the HTML branch of @use_cache.Tracking Parameters
The query string is part of the cache key. Without help, every ?utm_source=… or gclid value would render and store its own copy of a page, so ad clicks and shared links would always miss the cache. The API would be split the same way, because the module passes the page query on to Collection fetches.
The template's Caddyfile drops these parameters before the cache sees the request. A @tracking_query matcher decides when to run a uri query { … } block:
@tracking_query {
not path /_nuxt/*
expression `{http.request.uri.query}.matches("(^|&)(utm_source|utm_medium|…|mc_eid)(=|&|$)")`
}
uri @tracking_query query {
-utm_source
-utm_medium
-utm_campaign
# … the rest of the list
}
The full list is utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id, utm_source_platform, utm_creative_format, utm_marketing_tactic, gclid, gclsrc, gbraid, wbraid, dclid, srsltid, _gl, _ga, fbclid, msclkid, twclid, ttclid, li_fat_id, igshid, yclid, mc_cid and mc_eid.
- It only runs when the query contains a listed parameter. Caddy's
uri queryrewrites the whole query whenever it runs, even with nothing to remove, and turns a key with no value (?k) intok=. Other requests keep their query exactly as sent. A query with a tracking parameter and a valueless key still getsk=, which is harmless for pages. - It never runs for
/_nuxt/*. Innuxt dev, Vite asks for style blocks with URLs such as?vue&type=style&index=0&lang.css. Rewritten to?vue=&…, Vite served them as raw CSS and the app never mounted. - Every other path is covered,
/_apiincluded. A URL with these parameters gets the same cache entry as the URL without them. On pages, the query allowlist would drop them anyway. On/_apiand the paths the allowlist skips, any other query parameter still gets its own entry. - Only the server loses them. Analytics scripts (Google Analytics, Meta Pixel and so on) read them from the browser's address bar, so tracking still works.
- Nuxt and php never see them. If your project needs one on the server, remove it from the block and from the matcher's expression, and for pages add it to
CACHE_QUERY_ALLOWLISTtoo. To strip another parameter, add a-nameline and add the name to the expression. The two lists must match. Wildcards are not supported. The expression is CEL, so keep it on one line and put no#comments inside it. - The access log still shows the original URL. A
utm_parameter in Caddy's log does not mean the strip failed.
Rate Limit on Cache Misses
The template's Caddyfile limits how many requests each visitor can send that miss the cache. Over the limit, the visitor gets a 429 with a Retry-After header. The default is 3,000 requests a minute per visitor, in a sliding window, so a burst of the whole amount is allowed.
- Cache hits are never counted or refused. The limit runs inside the cache, so only requests that reach php or Nuxt count: misses, and paths the cache doesn't store.
/_nuxt/*and/.well-known/*(Mercure) are never limited.- Private client addresses are never limited. Server-side rendering, the deploy's cache warm and the kubelet's probes all reach php from inside the cluster or the compose network, and one SSR pod makes the API calls of every visitor it renders for.
The default is deliberately generous. A signed-in editor's requests are never cached, so one admin page makes dozens of uncached API calls, and an office or mobile network puts many visitors behind one address. It still stops a single client from sending misses as fast as it can. Tune it with RATE_LIMIT_EVENTS and RATE_LIMIT_WINDOW, or turn it off with RATE_LIMIT_ENABLED=false. Those are the names on the php container and in compose; as CI variables they're CWA_API_RATE_LIMIT_EVENTS and so on. See CI/CD Variables → Origin protection.
Counting the Real Visitor
The limit counts each visitor by their own address, never a proxy's. Counting by a proxy's address would throttle every visitor behind it at once.
- Behind Cloudflare there's nothing to configure. A request that arrives from one of Cloudflare's address ranges, directly or through ingress-nginx, is counted by its
CF-Connecting-IPheader. The header is ignored from anywhere else, because anyone can send it. See Cloudflare. - On Kubernetes with ingress-nginx, the ingress is a trusted proxy on a private address, so the visitor is read from
X-Forwarded-For, right to left. Entries a client puts in front of the real one are ignored. - On a single server (
compose.prod.yaml),CADDY_EDGE=trueis the default, so a direct client is counted by its own address and anX-Forwarded-Forit sends is never believed. - Behind another CDN or load balancer with public addresses, visitors are still counted separately, by the proxy's address plus the
X-Forwarded-Forheader. A client could then avoid the limit by inventing headers, so add the proxy's ranges toCADDY_TRUSTED_PROXIES.
To check what the limit sees, read visitor_ip in php's access log. It's the address the limit counts. client_ip is the address that connected to Caddy, which behind Cloudflare is Cloudflare's.
Cache-Control: no-cache always misses the cache. Souin forwards it to php or Nuxt every time, so it's a second way past the cache that the query allowlist doesn't close. The rate limit still counts it.Ingress rate limits
Caddy's rate limit on cache misses is the main protection, and it's on by default. ingress-nginx can add a coarser outer limit, which is off unless you set CWA_CI_INGRESS_RATE_LIMIT_RPS. bin/devops/k8s.sh then adds these annotations to the ingress:
| Variable | Annotation |
|---|---|
CWA_CI_INGRESS_RATE_LIMIT_RPS | nginx.ingress.kubernetes.io/limit-rps |
CWA_CI_INGRESS_RATE_LIMIT_BURST_MULTIPLIER | nginx.ingress.kubernetes.io/limit-burst-multiplier (nginx's default is 5) |
CWA_CI_INGRESS_RATE_LIMIT_CONNECTIONS | nginx.ingress.kubernetes.io/limit-connections |
The last two are only added when CWA_CI_INGRESS_RATE_LIMIT_RPS is set. Unlike Caddy's limit, nginx counts every request, cache hits and /_nuxt files included, and one page load is dozens of requests. Set it far above Caddy's RATE_LIMIT_EVENTS.
CWA_CI_INGRESS_RATE_LIMIT_RPS behind Cloudflare. nginx counts by the address that connected to it, which behind Cloudflare is a Cloudflare server shared by many visitors, so it would throttle real visitors together. Only use it there if the ingress controller itself is configured to read the visitor's address from Cloudflare.externalTrafficPolicy: Local on ingress-nginx's own Service. With the default Cluster, nginx sees node addresses instead of visitors' addresses. Those are private, so Caddy treats every visitor as internal and its rate limit never applies. That fails safe, with no limit at all. Local keeps the visitor's address; run the controller with 2 replicas and a PodDisruptionBudget, as the template's own cluster does (#135). Check visitor_ip in php's access log before you rely on the limit.Load shedding
php runs a fixed pool of 4 workers. When they're all busy, new requests wait for one. Left to queue, they would wait until Souin's 10-second backend timeout turned them into 504s, and every request behind them would do the same.
The production image gives up sooner. A request that waits FRANKENPHP_MAX_WAIT_TIME (default 5s) for a free worker gets a 503 at once. A flood then costs fast errors, and the queue empties as soon as it stops. Set it as the CI variable CWA_API_FRANKENPHP_MAX_WAIT_TIME (see Site settings): a lower value such as 2s sheds sooner, and 0 turns it off, so requests queue. It's FrankenPHP's max_wait_time, set in api/frankenphp/worker.Caddyfile, so it only applies to the production image's worker mode, here and in compose.prod.yaml. The dev stack has no worker pool.
5 seconds matches the probes' timeoutSeconds: 5, so a probe that can't get a worker fails exactly as it would have without shedding. Liveness needs 6 failures in a row, so a pod that's surviving a flood is only restarted if every probe for a minute is shed. Readiness takes it out of service after 3.
Cloudflare
Putting a CWA site behind Cloudflare. Proxy only is the recommended default. Edge caching has been tested end to end on the Free plan and is a supported choice, with an optional second rule for API responses.
Load Testing
Stress test a CWA site with the template's k6 script — how many visitors it serves, how quickly, and whether they got the page cache or a server-side render.