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. Free allows only 5 purges a minute. The template queues and paces purges within that (see Queued and paced purges), but Pro or above suits a site that editors update regularly.
Both end with a per-site checklist. To move a site to another Cloudflare account, see Moving a zone to another Cloudflare account.
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.
Each deploy track
The template deploys each track to its own hostname: production to the site's domain, staging to staging.<domain>, and review apps to <branch>-review.<domain>. Set them up like this:
| Track | DNS record | SSL/TLS mode |
|---|---|---|
| Production | Proxied, but DNS only until its first certificate is issued | Full (strict) |
| Staging | Proxied, but DNS only until its first certificate is issued, and always DNS only two levels below the zone (see below) | Full (strict) |
| Review apps | DNS only, on a wildcard record (*.<domain>) | Not used |
Staging keeps its certificate between deploys. Every production deploy runs delete staging, which uninstalls the staging release, so the staging hostname is down until the next staging deploy, whatever the proxy setting. The certificate isn't lost with it. cert-manager creates staging's Certificate from the ingress and deletes it with the ingress, but it keeps the Secret that holds the certificate, unless its controller runs with --enable-certificate-owner-ref, which is off by default. The Secret has the same name on every deploy (<CWA_CI_TLS_SECRET_NAME>-staging-api), so the next staging deploy finds a valid certificate for the same hostname and issues nothing. That means:
- The ingress serves the real certificate from its first second, so Full (strict) never sees a temporary one.
- No request reaches Let's Encrypt, so deploying staging and then production many times a day can't hit its limit of 5 identical certificates a week.
- The exception is a long gap. Nothing renews the Secret while staging is uninstalled. If staging goes more than about 90 days without a deploy, the next deploy serves the expired certificate for the minute it takes to renew it, and Cloudflare returns a 526 for that minute. It fixes itself.
If your cluster's cert-manager does run with --enable-certificate-owner-ref, every staging deploy issues a new certificate and briefly serves a temporary one. Keep staging DNS only on such a cluster.
example.com and *.example.com). A proxied hostname two levels down, such as staging.preview.cwa.rocks in the cwa.rocks zone, gets no valid certificate at Cloudflare's edge, so the browser's TLS handshake fails before the request reaches your origin. When the site's own domain is a subdomain, keep staging DNS only, or cover the deeper names with Advanced Certificate Manager (paid) or a custom certificate.Review apps stay DNS only. Cloudflare lets every plan proxy a wildcard record, but proxying review apps gains nothing and costs a lot:
- Under a domain that is itself a subdomain, review hostnames are two levels down and get no edge certificate on Free (see the callout above).
- Every review app is a new hostname. Its first certificate is issued while the ingress serves a temporary one, so Full (strict) returns a 526 for the first minute or so.
- A proxied wildcard turns every unused name into a 526 instead of a DNS miss.
Review apps are development hostnames with no real traffic, and nothing else on this page applies to them.
Leave Always Use HTTPS off (SSL/TLS → Edge Certificates). ingress-nginx already redirects HTTP to HTTPS at your origin, except for /.well-known/acme-challenge/, where cert-manager answers Let's Encrypt's HTTP-01 challenge over plain HTTP. Always Use HTTPS redirects at Cloudflare instead, before the request reaches the origin, and it makes no exception for that path. Let's Encrypt follows the redirect to HTTPS, and Cloudflare then connects to your origin over HTTPS with Full (strict). While the origin's certificate is missing or expired, Cloudflare refuses it, so the challenge fails: a hostname proxied before its first certificate can't get one, and an expired certificate (such as staging's after a long gap) can never renew. If a zone needs an edge redirect, turn Always Use HTTPS off and use a redirect rule that leaves out requests whose path starts with /.well-known/acme-challenge/.
Delete proxied records that no deploy serves. A leftover record, such as one for a retired staging host or an old wildcard, still answers through Cloudflare, with an error or the ingress's temporary certificate.
Leave Cloudflare's cache alone
Don't add Cache Rules in Option A. Cloudflare's default caching then does the right thing. It caches only files with static extensions (scripts, stylesheets, images, fonts, PDFs and so on), never HTML or JSON, and never a private response or one that sets a cookie:
- Pages and
/_apiresponses pass straight through to Souin. Theirs-maxageis meant for Souin, which is purged on every write. Without the setup in Option B: proxy with edge caching, purges don't reach Cloudflare, so it must not keep them. - Static files are served from the edge. That covers
/_nuxt/*, whose names change with every build, the files in Nuxt'spublic/folder, and media on a proxiedcdn.hostname, whose stored names are unique. A deleted upload stays at the edge until its lifetime runs out; purge its URL in Cloudflare if it must disappear at once.
/_nuxt request reaches php. Under a load test, that ran php out of memory (components-web-app#117). If a zone has one, delete it.Two settings make this safe:
- The service worker must not be cached. Cloudflare caches
.jsby default, and/sw.jskeeps its name across builds. The template sends it withCache-Control: public, max-age=0, must-revalidate, so Cloudflare passes every update check to the origin (see PWA › Serving the service worker). Without that header, add a Cache Rule that bypasses the cache for/sw.jsonly, or visitors get the update up to two hours late after a deploy. - Set Browser Cache TTL to Respect existing headers (Caching → Configuration). Any other value replaces a shorter
Cache-Controlfrom the origin on the files Cloudflare caches. A 4-hour setting turns/sw.js'smax-age=0intomax-age=14400for browsers, and givespublic/files such as the logo and favicon 4 hours in browsers. With Respect existing headers, the origin decides:/_nuxtfiles keep their immutable year,/sw.jsis always checked, and media keeps the bucket's header (components-web-app#120).
Check it:
curl -sI https://www.example.com/ | grep -i -E 'cf-cache-status|cache-status'
curl -sI https://www.example.com/sw.js | grep -i -E 'cf-cache-status|cache-control'
- A page and an
/_apiresponse showcf-cache-status: DYNAMIC(Cloudflare didn't cache it), next to Souin's owncache-statusheader. - A
/_nuxt/*.jsfile showsMISS, thenHITon the second request. /sw.jsis neverHIT, and itscache-controlis the template'spublic, max-age=0, must-revalidate, notmax-age=14400.
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_RANGES(CWA_API_CLOUDFLARE_IP_RANGESas a CI variable) 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 incompose.prod.yaml, where Caddy faces the internet itself) still recognises Cloudflare by its ranges.
getClientIp() isn't the visitor.Request::getClientIp() trusts X-Forwarded-For only from TRUSTED_PROXIES (private ranges), so behind Cloudflare it returns a Cloudflare address, shared by many visitors. Don't add Cloudflare's ranges to TRUSTED_PROXIES: that would let any client that connects through Cloudflare choose its own address. Use App\Http\VisitorIp instead, as below. Caddy's access log records the visitor as visitor_ip, next to client_ip, which is Cloudflare's.The visitor's address in your own code
Code that needs the visitor's address, such as a per-visitor rate limit on a form, injects the template's App\Http\VisitorIp and calls of():
use App\Http\VisitorIp;
use Symfony\Component\HttpFoundation\Request;
final class ContactFormLimiter
{
public function __construct(private readonly VisitorIp $visitorIp)
{
}
public function key(Request $request): string
{
return $this->visitorIp->of($request) ?? 'unknown';
}
}
of(Request): ?string returns the address Caddy's rate limit counts, by the same rules: CF-Connecting-IP only from Cloudflare's ranges, with CLOUDFLARE_IP_RANGES and CADDY_TRUSTED_PROXIES honoured. Caddy passes it to php as the CWA_VISITOR_IP server variable, an environment value that no request header can set. Without Caddy in front, in console commands and the functional tests, of() returns getClientIp().
The template's optional ingress-nginx limits (CWA_CI_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. A blocked IP stays blocked for 10 seconds. Pro allows two. Matching on the query string (to catch cache-busting requests) needs Pro or above. The rule is optional: the template's Caddy rate limit (RATE_LIMIT_EVENTS, 3000 a minute per visitor) already stops floods of cache misses, and edge cache hits cost the origin nothing. If you add one, a rule on paths starting with /_api with a limit of 1000 requests per 10 seconds is a reasonable start. SSR reaches the API inside the cluster, not through Cloudflare, so the rule counts only browser requests.
The limit has to be much higher than it sounds:
- Cloudflare counts edge cache hits. Only Business and Enterprise can count requests to the origin alone. With the API rule (see API responses (optional)), most
/_apirequests never reach the origin, but they still count. - Client-side navigation makes many
/_apicalls per page: the route, the page or page data, the layout, component groups, positions and components. A few quick page changes add up to dozens of requests, and an editor in the admin makes far more. - People share addresses. Editors in one office share one IP, and so one counter.
One downstream project set 100 per 10 seconds. A single visitor switching pages quickly was blocked with a 429 from Cloudflare, while the origin returned no 429s at all. It moved to 1000.
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 (see CI/CD › Warming the cache after a deploy).
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
What it needs
- A current template. Edge caching needs a patched Souin whose Cloudflare purge works, a
Cache-Tagheader on every tagged response, and full flushes and deploys that purge everything at the edge. The template has all three. See Releases. - A Cloudflare API token with only the Zone → Cache Purge permission, limited to the site's zone.
- A plan whose purge rate suits how often editors save. Free allows 5 purge requests a minute, shared by every site on the account. See Rate limits.
It was tested end to end on the Free plan; the results are at the end of this section, in Test results.
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:
- Run a current template: the patched Souin, the
Cache-Tagrule, and full flushes and deploys that purge everything at the edge. See Releases. - Create the API token, and set
CADDY_CACHE_CDN_CONFIGandCLOUDFLARE_API_TOKEN(see Secrets, next). - Deploy.
- Only then add the pages Cache Rule (see The cache rules must match the template's exclusions), and optionally the API rule (see API responses (optional)).
- Check that pages are cached and that edits purge them: see Checking purges.
Secrets
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. Leave it unset on a site without Cloudflare. - 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 below.
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.
The token must reach the deploy job. GitLab gives a Protected variable only to jobs on protected branches and tags. IfCLOUDFLARE_API_TOKENis protected and the deploy branch isn't,api_key $CLOUDFLARE_API_TOKENexpands to nothing. Leave the token unprotected and give it theproductionenvironment scope (see the rule below). Or protect it only if the deploy branch is protected too. - 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 }}.
Give the Cloudflare settings to production and staging only. Scope CADDY_CACHE_CDN_CONFIG and the token to the production environment. That scope includes staging on purpose: on GitLab, the staging job runs under environment: production, and staging can share production's database, so its edits must purge production's pages. Any deploy that has the config purges the whole zone: it sends a purge everything on every deploy and every fixture load, and tag purges on every edit. A review app given it would empty production's edge each time it deploys or loads fixtures. Without it, a review app gets the default strategy hard. The template doesn't check where the variable reaches, so the scope is the project's to get right.
- GitLab: set the environment scope of both variables to
production. - GitHub: the staging job uses a
stagingenvironment and review apps usereview/<branch>. Map the secret in theenv:of the staging and production deploy jobs only, not the review job, or store it as an environment secret ofproductionandstaging.
In the cluster, the chart stores the value in the release's Kubernetes Secret, not its ConfigMap.
A cdn setting with no value stops php from starting. Souin panics on a line such as api_key with nothing after it, so php crash-loops while helm reports the release deployed. So the deploy checks first, before anything is rolled out. A setting with no value, or a value that is still a $NAME reference, fails it:
CADDY_CACHE_CDN_CONFIG is incomplete, so php would not start:
'api_key' has no value
Check the variable it refers to reaches this job (on GitLab, a Protected variable is only given to protected branches) and that its reference is expanded.
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 the next section). 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 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 above leaves /_api out. The optional API rule (see API responses (optional)) caches it only for the exact Accept the module sends.
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.
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.
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.
Checking purges
Purge failures are logged in the php container's log. Nothing else reports them: the admin save still succeeds. The messages are:
| Message | Meaning |
|---|---|
Cloudflare purge of N tags rate limited, will retry in … | A 429. It's retried. |
Cloudflare purge of N tags failed with status N, will retry: … | A 5xx. It's retried. |
Cloudflare purge of N tags failed, will retry: … | A connection error or timeout. It's retried. |
Cloudflare purge of N tags failed with status N: … [tags] | Any other failure, such as a bad token. The purge is dropped. |
Cloudflare purge: N tags queued, more than the purge limit can send soon, so purging everything instead | A backlog became a Purge Everything. |
The same messages say Cloudflare purge everything instead of Cloudflare purge of N tags for a full flush.
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. On Free, after many saves, the purge may wait in the queue for a minute or more. If it still doesn't arrive, look for Cloudflare purge in the php container's log.
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, so the Caddyfile copiesSurrogate-KeyintoCache-Tag. Cloudflare indexes it and strips it before the response reaches the visitor, so you can't see it in a browser. Only responses for public hostnames get it. Internal hostnames, used by server-side rendering, don't: a hostname with no dot or ending in.local, such asphp.local. See the warning below. - 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 reaches Cloudflare, with any credential, and the failure is silent. The template patches Souin: it calls the right endpoint, gives each request a 30-second timeout and logs any failure.
Cache-Tag, and their /_api responses no Surrogate-Key, because of Node's header limit. Node refuses a response whose headers pass 16 KB by default. Surrogate-Key lists every resource in a collection, so /_api/_/routes alone passes that at about 230 routes; a site with about 1,900 routes sent 184 KB. Server-side rendering's request for the routes would fail, and the sitemap would come out empty (UND_ERR_HEADERS_OVERFLOW in the Nuxt log). So on a hostname with no dot, or one ending in .local (server-side rendering's, and localhost in development), Caddy removes Surrogate-Key from /_api responses after Souin has stored their tags. Purges are exact as before. To see an API response's tags, request it on a public host, or purge by the resource's IRI. Page HTML keeps Surrogate-Key on every host. Server-side rendering never goes through Cloudflare, so its hostnames don't need the tag. The app image also sets NODE_OPTIONS=--max-http-header-size=65536, which raises Node's limit to 64 KB. A deploy that sets its own NODE_OPTIONS replaces that, so add --max-http-header-size=65536 to yours.The cdn block is set up 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-http-cache --path or --tag, to purge one page (see Page Caching › Purging One Page) | Yes, those tags |
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-rendered-html and then the template's app:edge-cache:purge-everything, which purges the zone through Cloudflare's API without touching Souin's /_api cache. If either fails, it flushes with purge-http-cache instead (see CI/CD › Why every deploy clears the page cache) |
purge-http-cache --path /<path> or --tag <tag> (Purging one page). Keep Purge all cached data and purge-http-cache with no options as a last resort: they empty Souin's API responses as well as the edge, so the next visitor to each page waits for a full cold render, about 12 seconds on one CMS-heavy home page.Every row but the deploy's edge-only purge needs the patched Souin. The three 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 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 cache warm requests every page, and normal traffic does the rest.
Rate limits
Cloudflare limits purges by tag, hostname and prefix, and Purge Everything, all under one limit:
| Plan | Purge requests | Burst |
|---|---|---|
| Free | 5 per minute | 25 |
| Pro | 5 per second | 25 |
| Business | 10 per second | 50 |
| Enterprise | 50 per second | 500 |
- Every purge counts, not only full ones. A tag purge for one edited component counts the same as a Purge Everything. Each save in the admin is at least one request, and so are every deploy and every Purge all cached data.
- It counts requests, not tags. One request can carry up to 100 tags.
- The limit is per Cloudflare account, not per zone. Every site on the account shares it, and so do staging and production.
- Purging single URLs has its own, much higher limit (800 URLs a second on Free), but Souin purges by tag, so it doesn't help.
Check the current figures in Cloudflare's purge limits.
Queued and paced purges
The patched Souin doesn't send each purge to Cloudflare straight away. It queues them and sends them at the rate your plan allows:
- Tags are merged. Each zone has one sender. It drops duplicate tags and sends up to 100 tags per request, so a burst of saves becomes a few requests.
- The pace matches the plan. The sender keeps its own allowance, sized by
CLOUDFLARE_PURGE_PLAN, and waits for it to refill instead of sending more than Cloudflare accepts. - A refused purge is retried. After a
429it waits for Cloudflare'sRetry-After. A5xxor a connection error is retried too, after at least 5 seconds. Any other failure, such as a bad token (401or403), is logged and dropped. - A big backlog becomes one Purge Everything. When more tags are queued than the allowance can send within about a minute, the sender purges everything in the zone instead: one request, and nothing stale is left. A full flush also replaces anything queued.
Souin's own cache is still purged at once. Only the edge waits, so under heavy editing on Free an edit can take a minute or more to show at the edge, and backlogs turn into Purge Everything, which empties the edge cache for every page.
Settings
Four optional settings control the pace: CLOUDFLARE_PURGE_PLAN (free, pro, business or enterprise, which sets the rate and burst to that plan's limits, as in the table under Rate limits), and CLOUDFLARE_PURGE_REQUESTS, _WINDOW and _BURST to set them yourself. They're read from the php container's environment. In a deploy, set each as a CI variable named CWA_API_ plus the name, such as CWA_API_CLOUDFLARE_PURGE_PLAN. Defaults and details are in CI/CD Variables › CDN purging.
free is safe on every plan, but on a paid plan it delays purges for no reason, so set the plan you're on.
CWA_API_CLOUDFLARE_PURGE_REQUESTS=2 and CWA_API_CLOUDFLARE_PURGE_BURST=10 on each. Going over isn't fatal, because a 429 caused by a neighbour is retried, but it delays the purges.To give a site less than one request a minute, make the window longer than 1m: CWA_API_CLOUDFLARE_PURGE_WINDOW takes any positive Go duration. One account with six sites on Free set CWA_API_CLOUDFLARE_PURGE_REQUESTS=1, CWA_API_CLOUDFLARE_PURGE_WINDOW=90s and CWA_API_CLOUDFLARE_PURGE_BURST=3 on each. Together that's 4 requests a minute and a burst of 18, under Free's 5 and 25.A lower rate also turns a backlog into a Purge Everything sooner, because the sender coalesces once the queue needs more requests than its burst plus about a minute's allowance. At 1 per 90s with a burst of 3, a full allowance covers 3 requests plus two-thirds of one, so more than 300 queued tags become a Purge Everything.
The queue is held in memory. If the php pod crashes, purges still queued are lost, and their pages stay stale at the edge until a later purge clears them. A deploy purges everything, so a rollout is covered.
Test results
The setup above 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. |
| 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 (see API responses (optional)) | 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. |
Moving a zone to another Cloudflare account
Cloudflare has no way to move a zone between accounts, in the dashboard or the API. You add the domain to the new account as a new zone, rebuild it there, and then change the nameservers. Nothing is carried over: not DNS records, settings, rules or certificates (Move a domain between Cloudflare accounts). A domain registered with Cloudflare Registrar has its own inter-account transfer.
The site can move without downtime, because the template's ingress already serves a valid Let's Encrypt certificate for each hostname. Visitors can go straight to the origin while the new zone waits for its edge certificate.
- Turn off DNSSEC first, if the domain uses it. Cloudflare requires it before the nameservers change.
- Add the domain in the new account. The zone shows Pending until its nameservers point at it.
- Copy the DNS records. Export them from the old zone and import them into the new one, then check each record. Cloudflare's docs warn that skipping the export makes it import proxied records, which can give error 1000.
- Set every proxied record to DNS only in the new zone. On Free, a full-setup zone gets its Universal SSL certificate only after it activates, which takes 15 minutes to 24 hours, and it can't be validated in advance. A proxied record before then has no edge certificate. DNS only sends visitors to the ingress's Let's Encrypt certificate instead.
- Copy the settings and rules by hand: the SSL/TLS mode, Always Use HTTPS, Browser Cache TTL, Cache Rules, the rate limiting rule and Bot Fight Mode. The per-site checklist lists what each option needs.
- With edge caching, point purges at the new zone before it goes live. Create an API token limited to the new zone, put the new
zone_idinCADDY_CACHE_CDN_CONFIG, updateCLOUDFLARE_API_TOKEN, and deploy (see Secrets). A tag purge to a zone that's still Pending is refused with403 Unable to purge. Souin logs it and drops it, and nothing is lost, because the new zone has nothing cached yet. - Change the nameservers at the registrar, then select Re-check now on the new zone's Overview. The old zone shows Moved Away.
- Switch the records back to Proxied once the edge certificate is active (SSL/TLS → Edge Certificates). Edge caching only starts then.
cdn.<site> that points at a shared cdn. hostname in the old account. Point it at the origin directly instead, for example with an A record to the bucket's load balancer, as long as that origin has a certificate for the name. Cloudflare's own fix, Cloudflare for SaaS on the target's account, is the other option.Moving several sites to one account means they share its purge limit. Free allows 5 purge requests a minute for the whole account. Give each site a share with CWA_API_CLOUDFLARE_PURGE_REQUESTS, _WINDOW and _BURST, as in Settings.
Per-site checklist
Option A (every site):
- The origin serves a valid certificate on every hostname before its record is proxied.
- Each track's record matches Each deploy track: production and staging are Proxied, and the review wildcard is DNS only. Staging is DNS only if it is two levels below the zone.
- SSL/TLS mode is Full (strict) (SSL/TLS → Overview).
- Always Use HTTPS is off (SSL/TLS → Edge Certificates).
- No proxied record points at a hostname that no deploy serves. Delete leftovers.
- There are no Cache Rules, not even a bypass-everything one, and a page and an
/_apiresponse both showcf-cache-status: DYNAMIC, while a/_nuxt/*.jsfile showsHITon its second request. - Browser Cache TTL is Respect existing headers, and
/sw.jsis neverHITand keeps the template'scache-control. - If there's a rate limiting rule, you can browse quickly between pages, and edit in the admin, without a
429. - 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. - 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). On GitLab, both variables are scoped toproduction, and the token is protected only if the deploy branch is. No review deploy receives either (see the scoping rule in Secrets).- There's a pages Cache Rule, and optionally the API rule (see API responses (optional)), 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
Purging rendered HTML, then everything at Cloudflare (the new API pod's cache is already this build's)...thenPurged everything in the Cloudflare zone., with noThe edge-only purge failedline, 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, across every site on the Cloudflare account and including staging: 5 purge requests a minute on Free.
CLOUDFLARE_PURGE_PLANmatches the plan, and sites or environments sharing the account each have a lowerCLOUDFLARE_PURGE_REQUESTSandCLOUDFLARE_PURGE_BURST.
CI/CD Variables
Every CI/CD variable the template's pipeline reads, grouped by concern, with its default and the Helm value it sets. Starts with the few a new site must set.
Origin Protection
How the template keeps made-up URLs and request floods from getting past the cache - the query allowlist, tracking parameters, the rate limit on cache misses, ingress rate limits and load shedding.