Cloudflare
Cloudflare can sit in front of a CWA site as a proxy: it hides the origin, ends TLS close to the visitor, and blocks some abusive traffic before it reaches your cluster. There are two ways to set it up:
- Option A, proxy only. Cloudflare passes requests through and caches no pages. Souin, in the php container, still does all the page caching. This is the recommended default: it has the fewest moving parts.
- Option B, proxy with edge caching. Cloudflare also caches page HTML, and Souin purges it there. It has been tested end to end on the Free plan and is a supported choice when you want pages served from Cloudflare's edge.
Both end with a per-site checklist.
Option A: proxy only
DNS and TLS
- In DNS, set the site's records to Proxied (the orange cloud).
- In SSL/TLS → Overview, set the encryption mode to Full (strict). Cloudflare then connects to your origin over HTTPS and checks its certificate. The template's ingress already serves a Let's Encrypt certificate (see Ingress with TLS), so strict mode works as soon as that certificate is issued.
Leave Cloudflare's cache alone
Don't add Cache Rules in Option A. By default Cloudflare doesn't cache HTML or JSON, so pages and /_api responses pass straight through to Souin. Their s-maxage is meant for Souin, which is purged on every write. Without the setup in Option B, purges don't reach Cloudflare, so it must not keep them.
Check it with any page:
curl -sI https://www.example.com/ | grep -i -E 'cf-cache-status|cache-status'
You should see cf-cache-status: DYNAMIC (Cloudflare didn't cache it) next to Souin's own cache-status header.
/_nuxt/*, whose names change with every build, and for uploads, whose stored names are unique. If you delete an upload and it must disappear at once, purge its URL in Cloudflare too.Real client IPs
Behind Cloudflare, every request reaches your cluster from a Cloudflare address, and Cloudflare sends the visitor's address in the CF-Connecting-IP header. The template's Caddy rate limit already handles this, so there's nothing to configure for Cloudflare:
- Caddy uses
CF-Connecting-IPonly when the request comes from one of Cloudflare's published ranges. The ranges are built into the Caddyfile, andCLOUDFLARE_IP_RANGESreplaces them if Cloudflare adds new ones. From any other address the header is ignored, because anyone can send it. - Each visitor behind Cloudflare is counted separately, so one busy visitor can't get the whole Cloudflare edge throttled.
CADDY_EDGE=true(the default incompose.prod.yaml, where Caddy faces the internet itself) still recognises Cloudflare by its ranges.
Request::getClientIp() trusts X-Forwarded-For only from TRUSTED_PROXIES (private ranges), so behind Cloudflare it returns a Cloudflare address. Don't rely on the client IP in your own code. Caddy's access log records the visitor as visitor_ip, next to client_ip, which is Cloudflare's (template b3b8622). Don't add Cloudflare's ranges to TRUSTED_PROXIES either: that would let any client that connects through Cloudflare choose its own address.b5aa360: the TRUSTED_PROXIES default in api/.env listed 172.0.0.0/8, which includes public addresses such as Cloudflare's 172.64.0.0/13. It's now the private range 172.16.0.0/12. The Helm chart and compose.yaml always set 172.16.0.0/12, and a real environment variable wins over .env, so no template deployment used the old value. If you copied it into your project's .env, .env.local or anything else, change it to 172.16.0.0/12.The template's optional ingress-nginx limits (INGRESS_RATE_LIMIT_RPS and the related variables) count by the address that reached nginx. Behind Cloudflare that is a Cloudflare server, shared by many visitors, so leave them unset.
Free protection worth turning on
| Setting | Where | What it does |
|---|---|---|
| Rate limiting rule | Security rules → Rate limiting rules | Blocks an IP that sends too many matching requests. |
| Bot Fight Mode | Security → Settings | Challenges traffic that Cloudflare identifies as automated. |
| Under Attack Mode | the zone's Overview → Quick Actions | Emergency switch: every visitor gets an interstitial check before the site loads. |
Rate limiting. The Free plan allows one rule, counted per IP over 10 seconds, and its expression can match only on the path. Pro allows two. Matching on the query string (to catch cache-busting requests) needs Pro or above. A rule on paths starting with /_api is a reasonable start. SSR reaches the API inside the cluster, not through Cloudflare, so this counts only browser requests. Set the threshold generously: editors in one office share an IP, and the admin makes many API calls while editing. The template's own Caddy rate limit still applies behind it.
Bot Fight Mode can't be skipped by any rule or limited to some paths. It may challenge non-browser clients, including your pipeline's cache warm and performance audit, an uptime monitor, or a load test. After you turn it on, check that the next deploy's cache warm still gets 200s.
Under Attack Mode needs JavaScript to pass, so it challenges curl, your CI jobs and k6 while it's on. Use it only during an attack, and turn it off afterwards.
/_api, counted per IP over 10 seconds.Option B: proxy with edge caching
Edge caching needs a patched Souin whose Cloudflare purge works, and a Cache-Tag header on every tagged response. The template has both since cd99b55.
The setup on this page was tested end to end on preview.cwa.rocks, on Cloudflare's Free plan, with a scoped token (components-web-app#108):
| Check | Result |
|---|---|
| Pages are cached at the edge | Every sitemap page went MISS → HIT. |
Cache-Tag reaches Cloudflare | Yes, and Cloudflare removes it before the visitor sees the response. |
| An edit purges the affected page | That page went MISS, a sibling page stayed HIT, and no purge errors were logged. |
| A deploy purges every page | Every page went MISS within seconds. |
Purge all cached data / purge-http-cache | Doesn't reach Cloudflare, as expected. The edge kept serving its copy. |
| Mercure through Cloudflare | cf-cache-status: BYPASS, and live updates arrive. |
Requests with an api_component cookie | DYNAMIC with no-store. A request with an unrelated cookie still gets a HIT. |
/_api without the API rule | DYNAMIC |
/_api with the API rule | The module's Accept goes MISS → HIT. Any other Accept, an api_component cookie or an Authorization header is DYNAMIC, /_api/me is BYPASS, and an edit's tag purge drops the API response at the edge. |
Purges must reach Cloudflare
In production, the API tells shared caches to keep responses for a year (s-maxage=31557600), and pages inherit it (see How long a page is kept). Cloudflare honours s-maxage. If purges don't reach Cloudflare, an edited page stays stale at the edge for up to a year. So does a page that points at the previous build's /_nuxt/* files after a deploy, which then never becomes interactive (see Why every deploy clears the page cache).
Cloudflare purges by tag, and that needs two things:
- Responses must carry
Cache-Tagwhen Cloudflare stores them. Souin never sends that header, on a miss or a hit. Since template cd99b55, the Caddyfile copiesSurrogate-KeyintoCache-Tagon every response that has one. Cloudflare indexes it and strips it before the response reaches the visitor, so you can't see it in a browser. - Souin must send each tag purge to Cloudflare. Unpatched Souin v1.7.9, the version the template builds, posts to
/zones/<id>/purgeinstead of/purge_cache, so no purge ever reached Cloudflare, with any credential, and the failure was silent. Since cd99b55 the template patches Souin: it calls the right endpoint, sends 30 tags per request, gives each request a 30-second timeout and logs any failure.
Souin's cdn block comes from CADDY_CACHE_CDN_CONFIG, which defaults to strategy hard (no CDN). For Cloudflare it reads:
strategy hard
provider cloudflare
zone_id <your zone ID>
api_key $CLOUDFLARE_API_TOKEN
- Repeat
strategy hard. Setting the variable replaces the whole default. - Use a scoped API token, and leave out
email. With noemailline, the patched Souin sendsapi_keyas a bearer token, so a token with only the Zone → Cache Purge permission, limited to the site's zone, is enough. This works on the Free plan. With anemailline, Souin treatsapi_keyas a Global API Key instead, and a scoped token doesn't work that way. Don't use the Global API Key: it carries every permission your Cloudflare user has. - How
$CLOUDFLARE_API_TOKENgets its value is in Secrets.
Which purges reach Cloudflare:
| Purge | Reaches Cloudflare? |
|---|---|
| Saving content in the admin (tag purge of the changed IRIs) | Yes |
Site settings → purge page cache, and purge-rendered-html | Yes, the cwa-html tag |
Purge all cached data, purge-http-cache, and every fixture load (it ends with purge-http-cache) | Yes, everything in the zone |
The deploy purge, purge_rendered_html in k8s.sh | Yes, everything in the zone. With provider cloudflare in CADDY_CACHE_CDN_CONFIG, it runs purge-http-cache instead of purge-rendered-html |
Every row needs the patched Souin. The two tag rows also need the Cache-Tag rule; a full flush doesn't, because Souin sends it to Cloudflare as a purge everything. A tag purge removes exactly the pages carrying that tag, and IRI tags, which contain /, match.
The full-flush and deploy rows are new in template 6a8c898. Before it, a full flush cleared only Souin's own store, and the deploy purged only the cwa-html tag, so you had to use Purge Everything in Cloudflare (Caching → Configuration) after each full flush, and after a release that changed API output if you use the API rule. To get the new behaviour on an older project, take both the regenerated api/frankenphp/souin/v1.7.9-cloudflare-purge.patch and the purge_rendered_html change in bin/devops/k8s.sh, then rebuild the API image.
The deploy empties the edge for every hostname in the zone. A purge everything can't be limited to one hostname, and staging deploys share CADDY_CACHE_CDN_CONFIG with production. So a staging deploy also empties production's edge cache, as staging's tag purges already reach production's pages. The edge refills from the origin: the warm job requests every page, and normal traffic does the rest.
Rate limits. Cloudflare's purge-by-tag limit is 5 requests per minute on Free (bursts up to 25), 5 per second on Pro and 10 per second on Business. Each save in the admin is at least one request. An editor saving more than about five times a minute on a Free zone gets 429s, and those pages stay stale at the edge. Souin doesn't retry. Check the current figures in Cloudflare's purge limits.
Purge failures are logged. The patched Souin logs a refused purge (for example Cloudflare purge of tags [...] failed with status 429: ... or Cloudflare purge everything failed with status ...) or a connection error in the php container's log. Nothing else reports it: the admin save still succeeds.
There's also a narrow race: the Cloudflare purge starts before Souin deletes its own copy, so a request in that moment can refill the edge from the stale entry.
Checking that purges arrive. Request a page twice and see cf-cache-status: HIT. Edit something on that page in the admin, then request it again. You should get MISS or EXPIRED, with the new content. If not, look for Cloudflare purge in the php container's log.
Turning edge caching on
Cloudflare can only purge a page by tag if the response carried Cache-Tag when Cloudflare stored it. A page cached before that can't be purged by tag at all, and is kept for up to a year. So the order matters:
- Update to a template that includes cd99b55 (the patched Souin and the
Cache-Tagrule), or better 6a8c898, where full flushes and deploys also purge everything at the edge. - Create the API token, and set
CADDY_CACHE_CDN_CONFIGandCLOUDFLARE_API_TOKEN(see Secrets). - Deploy.
- Only then add the Cache Rule below.
Cache-Tag. Until then Cloudflare stores untagged pages, and after the next front-end deploy they point at /_nuxt/* files that no longer exist. A Purge Everything before that deploy doesn't help: the old image refills the edge with untagged pages straight away. From 6a8c898, that deploy purges everything itself, after the new image is rolled out, so the edge refills with tagged pages and nothing more is needed./_api needs its own rule
Cloudflare ignores Vary, apart from Accept-Encoding. The API negotiates on the Accept header (JSON-LD, JSON, or the HTML docs), and custom cache keys on headers are an Enterprise feature. So a rule that caches /_api by path alone could serve a response in the wrong format, which is the problem Souin hit in components-web-app#79. The pages rule below leaves /_api out. The optional API rule caches it only for the exact Accept the module sends.
The cache rules must match the template's exclusions
Cloudflare doesn't cache HTML unless a Cache Rule (on the Cache Rules page) marks it Eligible for cache. This is the pages rule that was tested. Replace the host with your own:
(http.host eq "preview.cwa.rocks" and not starts_with(http.request.uri.path, "/_api") and not http.cookie contains "api_component=")
Set it up like this:
| Setting | Value |
|---|---|
| Cache eligibility | Eligible for cache |
| Edge TTL | Use cache-control header if present, bypass cache if not |
| Browser TTL | Respect origin |
The Edge TTL setting does most of the work. The template's @use_cache matcher in api/frankenphp/Caddyfile decides what Souin may cache, and the origin's responses already say what may be shared: a cacheable page carries s-maxage, and /login, error pages and signed-in renders carry private or no-store, or no Cache-Control at all. With this setting Cloudflare caches only what the origin allows and bypasses everything else, so you don't have to copy every path from the matcher into the rule. The rule itself only has to leave out what the origin's headers can't protect:
/_api, because Cloudflare ignoresVary: Accept(see above). The optional API rule handles it separately.- Requests with an
api_componentcookie, so editors get their own render. The condition matches anyapi_componentcookie, including the empty one a signed-out browser can send, so it may bypass a request it didn't need to. In the test, requests with the cookie wereDYNAMIC. Signed-in renders are never stored either way, because they carryno-storeandSet-Cookie.
Other exclusions in the template's matcher, and how they're covered at the edge:
/.well-known/mercureis a stream (SSE). It sends no cacheable headers, and the test showedcf-cache-status: BYPASSwith live updates arriving./login,/forgot-password,/reset-password/*,/verify-email,/confirm-new-email,/user-areaand/_cwa/*are kept out by their own response headers, through the Edge TTL setting./_nuxt/*,/uploads/*and/bundles/*are static files. Cloudflare may cache them as it does in Option A, which is safe for the same reasons.
On the Free plan, Cache Rules can't use matches (regex needs Business) or http.request.cookies (needs Pro), which is why the cookie condition is a plain contains. Request-header fields do work on Free: the API rule's http.request.headers["accept"] and ["authorization"] conditions were accepted and behave as expected.
When the template's matcher changes, check the rule against it again.
Cloudflare also doesn't cache any response that carries Set-Cookie. If pages stay DYNAMIC under the rule, check for that first.
Tag size. Cloudflare reads at most 16 KB of Cache-Tag per response. A page's tags are its cwa-html tag plus every resource IRI it rendered from. The largest page in the test sent 1,628 bytes, 26 tags. A page with many more components has a longer header, and what Cloudflare does past the limit hasn't been checked.
API responses (optional)
A second rule lets Cloudflare cache API responses too. It speeds up the API calls the browser makes during client-side navigation. Server-side rendering calls the API inside the cluster, so it isn't affected. Use the same settings as the pages rule. The two rules can't overlap:
(http.host eq "preview.cwa.rocks"
and starts_with(http.request.uri.path, "/_api/")
and not starts_with(http.request.uri.path, "/_api/_/component_positions/")
and any(http.request.headers["accept"][*] in {"application/ld+json,application/json" "application/ld+json"})
and not http.cookie contains "api_component="
and not len(http.request.headers["authorization"]) gt 0)
Only the exact Accept the module sends (application/ld+json,application/json) and plain application/ld+json are eligible. That covers Vary: Accept, which Cloudflare ignores: every other Accept, such as a browser asking for the HTML docs, goes to Souin. Requests with an api_component cookie or an Authorization header are left out too. In the test, text/html, application/json and no Accept were all DYNAMIC, /_api/me was BYPASS, and an edit's tag purge dropped the API response at the edge.
Component positions are left out because their responses also vary by path. On a page built from page data, a position bound to a page-data property returns a different component for each page: the API picks the page data from the path request header the module sends, and marks the response Vary: path. Souin honours that, but Cloudflare ignores Vary and the Free plan can't add a request header to the cache key. Without the exclusion, the first page-data page whose position Cloudflare stored is served for all of them. Every blog article then shows one article's body after client-side navigation, while titles and server renders look right, and it stays that way for up to a year. Souin still caches positions, per path.
/_api/_/component_positions/, update it, then do one Purge Everything (or Purge all cached data in site settings). The wrong copies stay at the edge until they're purged.A deploy purges everything in the zone (see Purges must reach Cloudflare), so a release that changes API output, such as a bundle update or a serializer change, doesn't leave stale API responses at the edge.
cwa-html tag, which covers pages. After a release that changes API output, use Purge Everything in Cloudflare, or leave the API rule off.Edge lifetime
Keep the Edge TTL on Use cache-control header if present, bypass cache if not. Don't choose an override. Two reasons:
- "Ignore cache-control header and use this TTL" ignores the origin's
Cache-Control. The override replaces the origin's directives, soprivate, no-storeon a personalised response or an error page would no longer stop Cloudflare caching it. - The shortest Edge TTL depends on the plan: 2 hours on Free, 1 hour on Pro, 1 second on Business and Enterprise. On Free and Pro, a shorter TTL can't fix a missed purge quickly anyway.
With purges reaching Cloudflare, the origin's year-long s-maxage is what you want.
Secrets
The API token must not appear in pipeline logs.
- GitLab.
CADDY_CACHE_CDN_CONFIGhas several lines, and GitLab can't mask a multi-line variable. Put the token in its own masked variable,CLOUDFLARE_API_TOKEN. Then writeapi_key $CLOUDFLARE_API_TOKENinCADDY_CACHE_CDN_CONFIGand turn on Expand variable reference for that variable. GitLab substitutes the token before the deploy runs. Caddy doesn't expand$variables itself. - GitHub. GitHub doesn't expand one variable inside another, so put the token directly in
CADDY_CACHE_CDN_CONFIGand store the whole value as a secret. The workflows export repository variables to the deploy scripts automatically, but not secrets, so the deploy jobs'env:must also map it:CADDY_CACHE_CDN_CONFIG: ${{ secrets.CADDY_CACHE_CDN_CONFIG }}.
In the cluster, the chart stores the value in the release's Kubernetes Secret, not its ConfigMap.
Per-site checklist
Option A (every site):
- The origin serves a valid certificate on every hostname before its record is proxied.
- DNS records are Proxied.
- SSL/TLS mode is Full (strict) (SSL/TLS → Overview).
- There are no Cache Rules, and a page and an
/_apiresponse both showcf-cache-status: DYNAMIC. - A rate limiting rule is in place, with a threshold editors won't hit.
- Bot Fight Mode is on, and the next deploy's cache warm still gets
200s. - Someone on the team knows where Under Attack Mode is (Overview → Quick Actions) and when to use it.
- Nothing in the site's own code relies on the client IP, and the ingress-nginx rate limit variables are unset.
Option B (if you turn on edge caching):
- Everything in Option A, except the "no Cache Rules" check: a page shows
HITon its second request. Without the API rule, an/_apiresponse still showsDYNAMIC. With it, an/_apirequest with the module'sAcceptshowsHITon its second request, and withAccept: text/htmlit showsDYNAMIC. - The site runs a template that includes cd99b55: a page response from the origin carries
Cache-Tagmatching itsSurrogate-Key. CADDY_CACHE_CDN_CONFIGhasstrategy hard,provider cloudflare,zone_idandapi_key, and noemailline. The token has only Zone → Cache Purge for this zone, and is masked (GitLab, with Expand variable reference on forCADDY_CACHE_CDN_CONFIG) or a secret (GitHub).- That was deployed before the Cache Rule went in, or the site runs a template from 6a8c898, or one Purge Everything was done after that deploy.
- There's a pages Cache Rule, and optionally the API rule, with the tested expressions for your host (the API rule excludes
/_api/_/component_positions/), each with Eligible for cache, Edge TTL Use cache-control header if present, bypass cache if not, Browser TTL Respect origin. - An edit in the admin turns that page's
cf-cache-statusfromHITtoMISSorEXPIRED, and the php log has noCloudflare purgeerrors. - After a deploy, the deploy log says
Cloudflare is configured: flushing the HTTP cache, which also purges everything at Cloudflare..., and pages (and, with the API rule, API responses) areMISSat the edge and become interactive. - With the API rule, client-side navigation between pages built from the same page-data template shows each page's own content, not one page's content on all of them.
- Live updates still arrive, and
/.well-known/mercureshowscf-cache-status: BYPASS. - Purge all cached data in site settings turns a
HITpage intoMISSat the edge. - The plan's purge rate limit suits how often editors save: 5 purge requests a minute on Free.