Deploying a static site to Cloudflare Workers
A build-verify-deploy pipeline on Cloudflare's free tier, and the five traps that cost me an evening — including one that silently takes your staging URL offline.
I moved this site off a hosted blogging platform onto Cloudflare, with GitHub Actions doing the building and Cloudflare doing the serving. It costs nothing, deploys in about two and a half minutes, and refuses to publish anything that fails its checks.
Getting there took longer than it should have. Here is the setup, and the five things that tripped me up — none of which are obvious from the documentation.
The shape of it
git push
└─ GitHub Actions
├─ build generate the site
├─ verify dead links, missing images, bad metadata, broken redirects
├─ Lighthouse fail if performance/accessibility/SEO drop below budget
└─ deploy upload to Cloudflare
The important part is that deploy depends on the checks. A broken build never reaches
the internet. Pull requests get a preview URL; merges to main go live.
Trap 1: "Upload assets" gives you a Worker, not Pages
Cloudflare has two products that host static sites: Pages, the older one, and Workers, the current one. Their dashboard now puts the Upload assets button under Workers.
So you follow a guide that says "create a Pages project", you click the obvious button, and
you end up with a Worker. Your site is live at something.workers.dev, and the
something.pages.dev address every tutorial tells you to visit returns:
DNS_PROBE_FINISHED_NXDOMAIN
Nothing is broken. You simply never created a Pages project.
Both products work. Workers is the one Cloudflare is investing in, and it supports everything a static site needs — I verified each of these on a live deployment:
| Feature | Workers static assets |
|---|---|
_redirects |
yes |
_headers |
yes |
| Custom 404 page | yes |
| Custom domains | yes, if the nameservers are Cloudflare's |
Pick Workers and stop worrying about pages.dev.
Trap 2: the API token needs the right template
To deploy from CI you create a scoped API token. The obvious move is to build a custom one with a single permission. Don't.
A Worker deploy needs several permissions working together — Workers Scripts, plus read access to account and user details. Miss one and you get:
Authentication error [code: 10000]
which tells you nothing about which permission is absent. Use the built-in "Edit Cloudflare Workers" template instead; it ticks exactly the right boxes. Scope it to your account and your zone, and give it an expiry.
If you already made a Cloudflare Pages · Edit token because you thought you were using Pages, it will not deploy a Worker.
Test it before wiring it into CI:
export CLOUDFLARE_API_TOKEN=...
npx wrangler whoami
Ten seconds, and it prints the permissions it actually has.
Trap 3: wrangler turns things off when you're not looking
This one cost me the most time. Once you add a wrangler.jsonc, that file becomes the
complete description of your Worker — and anything absent from it is treated as disabled.
Deploy with a minimal config and you get:
▲ [WARNING] Because 'workers_dev' is not in your Wrangler file,
it will be disabled for this deployment by default.
▲ [WARNING] Because your 'workers.dev' route is disabled and your
'preview_urls' setting is not in your Wrangler file, Preview URLs
will be disabled for this deployment by default.
Two warnings among the normal output, and easy to skim past. The result: my staging URL
started returning 404, and per-version preview URLs stopped being generated — which would
have silently broken pull request previews, since the CI job reads the preview URL out of
wrangler versions upload.
A dashboard-created Worker has both on by default. Writing a config turns them off. Say so explicitly:
{
"name": "my-site",
"compatibility_date": "2026-08-26",
"workers_dev": true,
"preview_urls": true,
"assets": {
"directory": "./dist",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page"
},
"routes": [
{ "pattern": "www.example.com", "custom_domain": true },
{ "pattern": "example.com", "custom_domain": true }
]
}
not_found_handling: "404-page" serves your 404.html with a real 404 status.
auto-trailing-slash makes /about/ resolve to about/index.html.
Trap 4: the Custom Domain dialog matches zones, not hostnames
Adding www.example.com through the dashboard gave me:
No zones match www.example.com.
I own the domain. www is a subdomain of it, covered by the same zone. But the dialog was
matching against zone names — typing example.com found it, typing www.example.com found
nothing.
The error offers a helpful-looking link to "add the domain to Cloudflare". Do not click
it. That wizard creates a separate zone for www.example.com and offers to import DNS
records into it. You would end up with two overlapping zones and a genuine mess.
The fix is to skip the dashboard. Those routes entries above create the custom domains and
their DNS records on deploy, and re-assert them every time — so the configuration can't drift.
One thing to know: attaching fails while an old record is in the way.
Hostname 'example.com' already has externally managed DNS records (A, CNAME, etc).
Delete them first or try a different hostname. [code: 100117]
Delete the old records for that hostname, then deploy again.
Trap 5: overlapping _headers rules concatenate
Cache headers looked right in my config:
/assets/images/*
Cache-Control: public, max-age=31536000, immutable
/assets/*
Cache-Control: public, max-age=86400
What actually came back over the wire:
cache-control: public, max-age=86400, public, max-age=31536000, immutable
Every matching rule is applied and the values are joined. An image matches both, so it
gets two max-age values in one header. Browsers take the first — 1 day — and the year-long
immutable cache silently does nothing.
I only caught it because I curled the response headers after deploying. Make the rules non-overlapping:
/assets/images/*
Cache-Control: public, max-age=31536000, immutable
/assets/fonts/*
Cache-Control: public, max-age=31536000, immutable
/assets/app.js
Cache-Control: public, max-age=86400
If your domain also handles email
This is the part that makes people nervous, so it's worth being plain about it.
Your domain has two independent groups of records:
- Email lives in
MXandTXTrecords (SPF, DKIM) - Website lives in
AandCNAMErecords
Mail servers only read the first group. Browsers only read the second. Repointing your
website cannot affect mail delivery. The only way to break email is to delete an MX or
TXT record by hand.
So: change records one at a time, never use a bulk delete, and if a screen offers to remove
MX or TXT records, say no. Take a zone export first — Cloudflare has an Export records
button — and check afterwards:
dig +short MX example.com
dig +short TXT example.com | grep spf
Then send a real test email. DNS looking correct is not the same as mail flowing.
One deployment system, not two
The Worker's settings page offers to connect a Git repository. If you are deploying from GitHub Actions, leave it empty.
Connecting it enables Cloudflare's own build system, and you end up with two pipelines publishing the same site: yours, which runs checks, and Cloudflare's, which doesn't. They race, and whichever finishes last wins. A build your pipeline correctly refused to ship can get published anyway.
GitHub pushes to Cloudflare. Cloudflare never pulls.
Worth doing while you're in there
- Verify the deploy actually landed. Change something with a visible fingerprint — a
header, a version string — and
curlfor it. "The deploy succeeded" and "the new build is serving" are different claims. - Redirect old URLs, don't drop them. A
_redirectsfile with301lines preserves search ranking and keeps any link you've published elsewhere working. - Budget your Lighthouse scores in CI. A number that only gets checked when you remember to check it will drift.
None of this is exotic. It is a static site on free hosting. But the gap between "it deployed" and "it deployed correctly, and nothing else broke" is where the evening goes.