CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Deployment & Operations

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. 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

  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).

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:

TrackDNS recordSSL/TLS mode
ProductionProxied, but DNS only until its first certificate is issuedFull (strict)
StagingProxied, but DNS only until its first certificate is issued, and always DNS only two levels below the zone (see below)Full (strict)
Review appsDNS 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.

A proxied hostname must be at most one level below the zone. On the Free plan, Cloudflare's Universal SSL certificate covers only the zone's apex and its first-level subdomains (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 /_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: 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's public/ folder, and media on a proxied cdn. 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.
Don't add a Cache Rule that bypasses the cache for everything, as some account templates do. With it, every /_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 .js by default, and /sw.js keeps its name across builds. The template sends it with Cache-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.js only, 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-Control from the origin on the files Cloudflare caches. A 4-hour setting turns /sw.js's max-age=0 into max-age=14400 for browsers, and gives public/ files such as the logo and favicon 4 hours in browsers. With Respect existing headers, the origin decides: /_nuxt files keep their immutable year, /sw.js is 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 /_api response show cf-cache-status: DYNAMIC (Cloudflare didn't cache it), next to Souin's own cache-status header.
  • A /_nuxt/*.js file shows MISS, then HIT on the second request.
  • /sw.js is never HIT, and its cache-control is the template's public, max-age=0, must-revalidate, not max-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-IP only when the request comes from one of Cloudflare's published ranges. The ranges are built into the Caddyfile, and CLOUDFLARE_IP_RANGES (CWA_API_CLOUDFLARE_IP_RANGES as 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 in compose.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

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. 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 /_api requests never reach the origin, but they still count.
  • Client-side navigation makes many /_api calls 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.

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

What it needs

  • A current template. Edge caching needs a patched Souin whose Cloudflare purge works, a Cache-Tag header 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:

  1. Run a current template: the patched Souin, the Cache-Tag rule, and full flushes and deploys that purge everything at the edge. See Releases.
  2. Create the API token, and set CADDY_CACHE_CDN_CONFIG and CLOUDFLARE_API_TOKEN (see Secrets, next).
  3. Deploy.
  4. 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)).
  5. Check that pages are cached and that edits purge them: see Checking purges.
If the Cache Rule went in before the deploy, nothing more is needed. That deploy purges everything at the edge after the new image is rolled out, so the edge refills with tagged pages.

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 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 below.

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.
    The token must reach the deploy job. GitLab gives a Protected variable only to jobs on protected branches and tags. If CLOUDFLARE_API_TOKEN is protected and the deploy branch isn't, api_key $CLOUDFLARE_API_TOKEN expands to nothing. Leave the token unprotected and give it the production environment 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_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 }}.

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 staging environment and review apps use review/<branch>. Map the secret in the env: of the staging and production deploy jobs only, not the review job, or store it as an environment secret of production and staging.

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:

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 the next section). 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 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, 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.

Checking purges

Purge failures are logged in the php container's log. Nothing else reports them: the admin save still succeeds. The messages are:

MessageMeaning
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 insteadA 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-Tag when Cloudflare stores them. Souin never sends that header, on a miss or a hit, so the Caddyfile copies Surrogate-Key into Cache-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 as php.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>/purge instead 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.
Internal hostnames get no 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:

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-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.shYes, 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)
To clear stale content, purge the page or the tag, not everything. Use the admin's purge for one page, or 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:

PlanPurge requestsBurst
Free5 per minute25
Pro5 per second25
Business10 per second50
Enterprise50 per second500
  • 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 429 it waits for Cloudflare's Retry-After. A 5xx or a connection error is retried too, after at least 5 seconds. Any other failure, such as a bad token (401 or 403), 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.

Each php process counts on its own, but Cloudflare's limit is per account. When staging and production, or several sites, share an account, give each a share of the allowance. For example, on Free with two sites, set 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.

Pro or above suits a site that editors update regularly. On Free, purges aren't lost, but under heavy editing they arrive late, and backlogs become a Purge Everything that empties the edge cache. Free is fine for a site that changes rarely.

Test results

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.

  1. Turn off DNSSEC first, if the domain uses it. Cloudflare requires it before the nameservers change.
  2. Add the domain in the new account. The zone shows Pending until its nameservers point at it.
  3. 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.
  4. 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.
  5. 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.
  6. 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_id in CADDY_CACHE_CDN_CONFIG, update CLOUDFLARE_API_TOKEN, and deploy (see Secrets). A tag purge to a zone that's still Pending is refused with 403 Unable to purge. Souin logs it and drops it, and nothing is lost, because the new zone has nothing cached yet.
  7. Change the nameservers at the registrar, then select Re-check now on the new zone's Overview. The old zone shows Moved Away.
  8. Switch the records back to Proxied once the edge certificate is active (SSL/TLS → Edge Certificates). Edge caching only starts then.
A proxied CNAME to a hostname in another account's zone fails with error 1014 (CNAME Cross-User Banned). Watch for this with a media hostname such as 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.

Image placeholder: the new zone's DNS records page with every record set to DNS only, next to SSL/TLS → Edge Certificates showing the Universal certificate not yet active.

Per-site checklist

Option A (every site):

  1. The origin serves a valid certificate on every hostname before its record is proxied.
  2. 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.
  3. SSL/TLS mode is Full (strict) (SSL/TLS → Overview).
  4. Always Use HTTPS is off (SSL/TLS → Edge Certificates).
  5. No proxied record points at a hostname that no deploy serves. Delete leftovers.
  6. There are no Cache Rules, not even a bypass-everything one, and a page and an /_api response both show cf-cache-status: DYNAMIC, while a /_nuxt/*.js file shows HIT on its second request.
  7. Browser Cache TTL is Respect existing headers, and /sw.js is never HIT and keeps the template's cache-control.
  8. If there's a rate limiting rule, you can browse quickly between pages, and edit in the admin, without a 429.
  9. Bot Fight Mode is on, and the next deploy's cache warm still gets 200s.
  10. Someone on the team knows where Under Attack Mode is (Overview → Quick Actions) and when to use it.
  11. 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. 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). On GitLab, both variables are scoped to production, and the token is protected only if the deploy branch is. No review deploy receives either (see the scoping rule in Secrets).
  4. 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.
  5. 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.
  6. 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)... then Purged everything in the Cloudflare zone., with no The edge-only purge failed line, and pages (and, with the API rule, API responses) are MISS at the edge and become interactive.
  7. 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.
  8. Live updates still arrive, and /.well-known/mercure shows cf-cache-status: BYPASS.
  9. Purge all cached data in site settings turns a HIT page into MISS at the edge.
  10. 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_PLAN matches the plan, and sites or environments sharing the account each have a lower CLOUDFLARE_PURGE_REQUESTS and CLOUDFLARE_PURGE_BURST.