Skip to content

DEVOPS-5014: Cloudflare Workers Static Assets (no Worker script) - #2122

Draft
Shabablinchikow wants to merge 8 commits into
masterfrom
cloudflare-migration
Draft

DEVOPS-5014: Cloudflare Workers Static Assets (no Worker script)#2122
Shabablinchikow wants to merge 8 commits into
masterfrom
cloudflare-migration

Conversation

@Shabablinchikow

@Shabablinchikow Shabablinchikow commented Aug 7, 2026

Copy link
Copy Markdown

Moves docs.wallarm.com from Netlify to Cloudflare Workers Static Assets (DEVOPS-5014).

Additive. netlify.toml and netlify/ are untouched, so Netlify keeps building and serving production throughout.

Verified end to end on https://docs-staging.wallarm.com — a real hostname on the same Cloudflare zone, delegated in terraform-route53 and wired up in infra/cloudflare-iac.

Six files

File Purpose
wrangler.jsonc Assets-only deployment. No Worker script.
docs/6.x/_headers Port of the netlify.toml [[headers]] blocks
docs/6.x/_redirects Made deployable on Cloudflare — see below
scripts/build.sh The netlify.toml [build] command, extracted
scripts/check_markdown_companions.py Build-time guard, see below
.gitignore Ignores wrangler local state

There is no Worker

The original port carried src/worker.ts, a translation of netlify/edge-functions/markdown-negotiation.ts. It is gone. Accept: text/markdown negotiation is now three zone rules in infra/cloudflare-iac (markdown_negotiation.tf).

Why it mattered: a plain HTML page view has no Accept header to rewrite on, so the Worker ran on every page view. Measured on staging with the Worker in place: 24 requests produced 13 invocations. After removing it, 80+ requests produced zero. Static asset requests are free and unlimited; Worker script requests are billed.

Removing it also fixed a class of bug rather than an instance: the Worker had to re-set CSP, HSTS and nosniff in code purely because _headers does not apply to responses returned from Worker code. With no Worker, _headers applies directly.

What was given up

  • X-Markdown-Tokens — computed from body length, not expressible in a rule.
  • Per-page Link: rel=alternate — response-header expressions reject wildcard_replace (API error 20087). Now a static llms.txt service-doc; the rendered HTML still carries <link rel="alternate"> in <head>.
  • The runtime fallback when a .md companion is missing. This is the one that needed replacing, hence check_markdown_companions.py.

The script is recoverable from git history if the rules disappoint under production traffic.

scripts/check_markdown_companions.py

A URL rewrite is blind: Cloudflare cannot test whether the .md exists. A page without a companion does not fall back to HTML — it returns the 404 page with Content-Type: text/markdown, because the _headers rule matches the request path. An agent gets 279 KB of HTML claiming to be Markdown.

The Worker checked md.ok and fell back. Losing that is only safe if every page has a companion is enforced, so this fails the build instead. Current run: 931 pages checked, 3 exempt, 0 missing. The exempt three are version roots — theme-assembled landing pages — and its exemption list must stay in sync with local.md_no_companion in markdown_negotiation.tf.

_redirects was a hard deploy blocker

Cloudflare rejects the entire deployment past 100 dynamic rules — not the overflow, the whole thing:

Maximum number of dynamic _redirects rules limit of 100 exceeded [code: 100324]

Three separate causes, all found by actually deploying:

1. Ordering — the real one. Cloudflare charges every rule appearing after the first wildcard rule against the dynamic budget, whatever that rule itself looks like. A wildcard on line 22 made ~700 plain rules count as dynamic. Its own warning said so, and I first read it as a performance hint. Static rules now come first, wildcards are grouped at the end. This is also better semantics — exact matches win over wildcards — and Netlify is unaffected, being first-match-wins in file order either way.

2. 128 per-version boilerplate rules (/4.8/admin-en/*/4.8, across fifteen retired versions) replaced by one Cloudflare Redirect Rule. Agreed with the docs team: those paths now land on the version-list page rather than a per-version stub that is not built and 404s today. The fifteen legacy rules that map onto live pages (/2.12/api/*/api/:splat) stay here, and the Cloudflare rule explicitly excludes their prefixes so it cannot shadow them.

3. Invalid syntax. 20 rules used Netlify's 301! force suffix, which Cloudflare parses as status 0. Redirects already run before assets there, so a plain 301 is equivalent. 3 rules duplicated a path defined earlier and were dropped — Cloudflare treats /foo and /foo/ as distinct, so only exact collisions were removed.

Verified on docs-staging.wallarm.com

negotiation on 6.x / 7.x / 5.x pages and / 200 text/markdown, authored companion
same paths plain 200 text/html
cache poisoning, both orders, cold pages no leakage in either direction
404 guard — version roots with Accept: text/markdown 200 text/html fallback
security headers CSP, HSTS, nosniff from _headers
Vary: Accept, Link present
assets, feeds, llms.txt, sitemap and favicon rewrites 200, correct content types
legacy + content redirects correct, including the preserved :splat rules
Worker invocations 0

Still needed before cutover

  • Workers Builds must be connected in the Cloudflare dashboard. This PR deliberately carries no CI: the GitHub Actions workflow it originally had was superseded by that decision and has been removed. Netlify keeps serving production until then.
  • The rules in cloudflare-iac are scoped to docs-staging.wallarm.com; widen to docs.wallarm.com at cutover.
  • The cutover is a Cloudflare-side DNS change, not a Route53 one. wallarm.com is a partial zone, so Cloudflare holds its own record for the proxied hostname: CNAME docs.wallarm.com → pensive-dubinsky-5f7a00.netlify.app. Rollback is restoring that value.
  • cloudflare_workers_custom_domain will not work — Custom Domains refuse a hostname that already carries a CNAME and are documented against full zones. Production needs the same route-based treatment staging uses.

🤖 Generated with Claude Code

Ports the Netlify setup to Cloudflare Workers + Static Assets (DEVOPS-5014).
Additive: netlify.toml is untouched and Netlify stays the live site until the
DNS cutover is verified, so rollback is "do not switch DNS".

- wrangler.jsonc: assets served from site/, ASSETS binding, run_worker_first
  limited to extension-less page URLs so images/fonts/CSS stay on the asset
  fast path (no billed Worker invocation, and _headers keeps applying).
- src/worker.ts: port of netlify/edge-functions/markdown-negotiation.ts. Keeps
  Accept: text/markdown negotiation, Vary: Accept and the RFC 8288 Link
  headers. Drops the User-Agent/IP scraper blocks — those become Cloudflare
  WAF rules, which run in front of the Worker and cost nothing.
- docs/6.x/_headers: port of the netlify.toml [[headers]] blocks. Cloudflare's
  splat is greedy, so one /*.md rule replaces Netlify's /*.md + /**/*.md pair.
- docs/6.x/_redirects: ports the two netlify.toml 200-rewrites (favicon.ico and
  the sitemap.xml catch-all).
- scripts/build.sh: the netlify.toml [build] command, extracted so both
  platforms run the same steps. Keep in sync until netlify.toml is removed.
- .github/workflows/cloudflare.yml: PR to master uploads a version with a
  stable per-PR preview alias and comments the link; merge to master deploys
  and purges the zone cache.

Verified locally with `wrangler dev`: markdown negotiation on pages, HTML
fallback when no .md companion exists, _headers applied to assets that bypass
the Worker, and _redirects (301s and 200-rewrites) taking precedence over it.
@netlify

netlify Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploy Preview for pensive-dubinsky-5f7a00 ready!

Name Link
🔨 Latest commit 96dbbd0
🔍 Latest deploy log https://app.netlify.com/projects/pensive-dubinsky-5f7a00/deploys/6a76213d5dbb01000732f9cc
😎 Deploy Preview https://deploy-preview-2122--pensive-dubinsky-5f7a00.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

wrangler.jsonc gains env.staging (wallarm-docs-staging), served at
docs-staging.wallarm.com so the whole flow can be exercised before
docs.wallarm.com moves. A separate Worker rather than a preview version of the
production one, so staging can be redeployed without touching whatever
production is serving. Routes stay out of wrangler and live in
infra/cloudflare-iac - two systems must not both own them.

build.sh gains SKIP_IMAGE_OPTIMISATION. The image pass downloads x86_64 Linux
binaries, so a production-mode build could not run on a developer machine at
all - and since the .md companions the Worker serves are only generated in
production mode, the Worker's whole reason for existing was untestable
locally.
Cloudflare rejects the whole deployment, not just the overflow:

    Maximum number of dynamic _redirects rules limit of 100 exceeded [100324]

Three separate problems, found by actually deploying:

1. ORDERING, which was the real one. Cloudflare charges every rule appearing
   *after* the first wildcard rule against the 100-"dynamic" budget, whatever
   that rule itself looks like. A wildcard on line 22 made ~700 plain rules
   count as dynamic. Its own warning said so - "could be made more performant
   by bringing it above any lines with splats" - and I read that as a
   performance hint rather than a hard limit. Static rules now come first and
   wildcards are grouped at the end, which is also better semantics: exact
   matches win over wildcards.

2. 128 per-version boilerplate rules (/4.8/admin-en/* -> /4.8 and friends,
   across fifteen retired versions) are replaced by one Cloudflare Redirect
   Rule in infra/cloudflare-iac. Agreed with the docs team: those paths now
   land on the version-list page instead of a per-version stub that is not
   built and 404s today. The fifteen legacy rules that map onto live pages
   (/2.12/api/* -> /api/:splat) stay here, and the Cloudflare rule explicitly
   excludes their prefixes so it cannot shadow them.

3. 20 rules used Netlify's `301!` force syntax, which Cloudflare parses as
   status 0 and rejects. Redirects already run before assets there, so a plain
   301 is equivalent. 3 rules duplicated a path already defined earlier and
   were dropped - Cloudflare treats /foo and /foo/ as distinct, so only exact
   collisions were removed.

Netlify is unaffected: it is first-match-wins in file order either way.
src/worker.ts existed for Accept: text/markdown negotiation. That is now
three zone rules in infra/cloudflare-iac, which cost nothing to run where a
Worker script is billed per request - and the script ran on every HTML page
view, since a plain page request has no Accept header to rewrite on.
Measured on staging: 24 requests produced 13 invocations, all from paths the
rules did not yet cover.

Where each responsibility went:
  negotiation           -> URL rewrite rules
  fallback on a missing companion -> scripts/check_markdown_companions.py,
     which fails the build instead. A rewrite is blind, so a missing .md is
     not a fallback to HTML but a 404 mislabelled Content-Type: text/markdown.
  Vary: Accept, Link    -> response-header rules
  CSP / HSTS / nosniff  -> _headers, which applies again now. Those were only
     re-set in code because _headers does not apply to Worker responses.

Lost, accepted: X-Markdown-Tokens (computed from body length, not expressible
in a rule) and the per-page Link rel=alternate (response-header expressions
reject wildcard_replace). The HTML head still carries the alternate link.

Recoverable from git history if the rules disappoint on production traffic.
My local production build ran the pngquant/oxipng pass over images/, which
rewrites the files in place, and git add -A swept 1115 of them into the
previous commit. The optimisation is meant to run only on the ephemeral CI
checkout - netlify.toml and scripts/build.sh both say full-resolution
originals stay in git - so committing the lossy output would have destroyed
the sources.

Restored from master. Nothing else in that commit is affected.
Superseded before it ever ran. The agreed deploy path is Cloudflare Workers
Builds, which is the direct Netlify equivalent - git integration, build
command, production branch deploys, per-branch preview URLs - and needs none
of the four repo secrets this workflow required.

Workers Builds is configured in the Cloudflare dashboard rather than here, so
this leaves the repo with no CI of its own until that is connected. Netlify
continues to build and serve production in the meantime, which is the whole
point of keeping netlify.toml untouched.
@Shabablinchikow Shabablinchikow changed the title DEVOPS-5014: Cloudflare Workers deployment (alongside Netlify) DEVOPS-5014: Cloudflare Workers Static Assets (no Worker script) Aug 7, 2026
wrangler.jsonc: workers_dev = false. It would otherwise publish the whole site
a second time on *.workers.dev, and zone rules do NOT apply there - verified
against staging:

  Accept: text/markdown   workers.dev -> text/html   zone -> text/markdown
  /4.8/admin-en/foo/      workers.dev -> 404         zone -> 301
  Vary                    workers.dev -> absent      zone -> present

So that copy had no redirects, no markdown negotiation, no WAF and no bot
protection, and search engines could index it as duplicate content. After the
change workers.dev returns 404 and the zone hostname still serves.

preview_urls stays on: Workers Builds gives each non-production branch a
versioned preview, which is how PR review works. Those are on workers.dev too,
so previews show content accurately but not routing - a redirect or
negotiation change has to be checked on docs-staging.wallarm.com.

.python-version pins 3.14; the build image defaults to 3.13.

DEPLOY-CLOUDFLARE.md records the Workers Builds settings, which live in the
dashboard rather than the repo and would otherwise be undocumented: the
submodule init the build needs, CONTEXT=production (without it the .md
companions are never generated and negotiation 404s), and
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD, since package.json drags in ~170 MB of
Chromium the docs build never uses.

CLAUDE.md now describes both pipelines instead of Netlify alone, including the
two things that hard-fail a Cloudflare build: redirect ordering and a missing
.md companion.
Second time: timing the production build re-ran the pngquant/oxipng pass,
which rewrites images/ in place, and git add -A committed the lossy output
over the full-resolution sources.

The behaviour is correct on CI, where the checkout is ephemeral. It is only
dangerous locally, so build.sh now says so at the point it happens and points
at SKIP_IMAGE_OPTIMISATION=1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant