The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
Deployment

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.

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

  1. In DNS, set the site's records to Proxied (the orange cloud).
  2. 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.
Get the origin certificate before you turn the proxy on. With Full (strict), Cloudflare refuses an origin whose certificate isn't valid, and visitors get a Cloudflare error page. For a new site or a new hostname, the simplest order is: leave the record DNS only (grey cloud), deploy, check that the site serves a valid certificate, then switch the record to Proxied.
Image placeholder: Cloudflare's SSL/TLS overview with the encryption mode set to Full (strict).

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.

Cloudflare's defaults still cache static files by file extension: images, scripts, stylesheets, PDFs and so on, for as long as their headers allow. That's safe for /_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-IP only when the request comes from one of Cloudflare's published ranges. The ranges are built into the Caddyfile, and CLOUDFLARE_IP_RANGES replaces 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 in compose.prod.yaml, where Caddy faces the internet itself) still recognises Cloudflare by its ranges.
Symfony doesn't see the visitor's address.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.
Projects created before template 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

SettingWhereWhat it does
Rate limiting ruleSecurity rules → Rate limiting rulesBlocks an IP that sends too many matching requests.
Bot Fight ModeSecurity → SettingsChallenges traffic that Cloudflare identifies as automated.
Under Attack Modethe zone's Overview → Quick ActionsEmergency 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.

Image placeholder: the rate limiting rule editor with a path rule for /_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):

CheckResult
Pages are cached at the edgeEvery sitemap page went MISS → HIT.
Cache-Tag reaches CloudflareYes, and Cloudflare removes it before the visitor sees the response.
An edit purges the affected pageThat page went MISS, a sibling page stayed HIT, and no purge errors were logged.
A deploy purges every pageEvery page went MISS within seconds.
Purge all cached data / purge-http-cacheDoesn't reach Cloudflare, as expected. The edge kept serving its copy.
Mercure through Cloudflarecf-cache-status: BYPASS, and live updates arrive.
Requests with an api_component cookieDYNAMIC with no-store. A request with an unrelated cookie still gets a HIT.
/_api without the API ruleDYNAMIC
/_api with the API ruleThe 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-Tag when Cloudflare stores them. Souin never sends that header, on a miss or a hit. Since template cd99b55, the Caddyfile copies Surrogate-Key into Cache-Tag on 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>/purge instead 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 no email line, the patched Souin sends api_key as 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 an email line, Souin treats api_key as 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_TOKEN gets its value is in Secrets.

Which purges reach Cloudflare:

PurgeReaches Cloudflare?
Saving content in the admin (tag purge of the changed IRIs)Yes
Site settings → purge page cache, and purge-rendered-htmlYes, 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.shYes, 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:

  1. Update to a template that includes cd99b55 (the patched Souin and the Cache-Tag rule), or better 6a8c898, where full flushes and deploys also purge everything at the edge.
  2. Create the API token, and set CADDY_CACHE_CDN_CONFIG and CLOUDFLARE_API_TOKEN (see Secrets).
  3. Deploy.
  4. Only then add the Cache Rule below.
On a template before 6a8c898, if the Cache Rule went in first, do one Purge Everything after the first deploy that sends 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:

SettingValue
Cache eligibilityEligible for cache
Edge TTLUse cache-control header if present, bypass cache if not
Browser TTLRespect 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 ignores Vary: Accept (see above). The optional API rule handles it separately.
  • Requests with an api_component cookie, so editors get their own render. The condition matches any api_component cookie, 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 were DYNAMIC. Signed-in renders are never stored either way, because they carry no-store and Set-Cookie.

Other exclusions in the template's matcher, and how they're covered at the edge:

  • /.well-known/mercure is a stream (SSE). It sends no cacheable headers, and the test showed cf-cache-status: BYPASS with live updates arriving.
  • /login, /forgot-password, /reset-password/*, /verify-email, /confirm-new-email, /user-area and /_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.

Image placeholder: the Cache Rules page with the eligible rule for page HTML, showing the expression, the Edge TTL set to "Use cache-control header if present, bypass cache if not" and the Browser TTL set to "Respect origin".

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.

If you added the API rule before it excluded /_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.

On a template before 6a8c898, a deploy doesn't purge edge-cached API responses. Its deploy purge removes only the 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, so private, no-store on 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_CONFIG has several lines, and GitLab can't mask a multi-line variable. Put the token in its own masked variable, CLOUDFLARE_API_TOKEN. Then write api_key $CLOUDFLARE_API_TOKEN in CADDY_CACHE_CDN_CONFIG and 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_CONFIG and 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):

  1. The origin serves a valid certificate on every hostname before its record is proxied.
  2. DNS records are Proxied.
  3. SSL/TLS mode is Full (strict) (SSL/TLS → Overview).
  4. There are no Cache Rules, and a page and an /_api response both show cf-cache-status: DYNAMIC.
  5. A rate limiting rule is in place, with a threshold editors won't hit.
  6. Bot Fight Mode is on, and the next deploy's cache warm still gets 200s.
  7. Someone on the team knows where Under Attack Mode is (Overview → Quick Actions) and when to use it.
  8. 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):

  1. Everything in Option A, except the "no Cache Rules" check: a page shows HIT on its second request. Without the API rule, an /_api response still shows DYNAMIC. With it, an /_api request with the module's Accept shows HIT on its second request, and with Accept: text/html it shows DYNAMIC.
  2. The site runs a template that includes cd99b55: a page response from the origin carries Cache-Tag matching its Surrogate-Key.
  3. CADDY_CACHE_CDN_CONFIG has strategy hard, provider cloudflare, zone_id and api_key, and no email line. The token has only Zone → Cache Purge for this zone, and is masked (GitLab, with Expand variable reference on for CADDY_CACHE_CDN_CONFIG) or a secret (GitHub).
  4. 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.
  5. 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.
  6. An edit in the admin turns that page's cf-cache-status from HIT to MISS or EXPIRED, and the php log has no Cloudflare purge errors.
  7. 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) are MISS at the edge and become interactive.
  8. 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.
  9. Live updates still arrive, and /.well-known/mercure shows cf-cache-status: BYPASS.
  10. Purge all cached data in site settings turns a HIT page into MISS at the edge.
  11. The plan's purge rate limit suits how often editors save: 5 purge requests a minute on Free.