
The interesting question is not "how do I put a static site on Cloudflare Workers" - that part takes about four minutes. It is whether Workers now replaces the S3 + CloudFront pattern that has been the default answer for a decade.
Since Workers gained first-class static asset support, the answer is closer to yes than most people realise, and for one specific reason: requests to static assets are free and unlimited on both the Free and Paid plans. Not metered-but-cheap. Free.
That single line rewrites the cost model, and most of this guide is about what it does and does not cover.
What Is Cloudflare Workers?
Workers is Cloudflare's serverless runtime. Your code runs on V8 isolates in Cloudflare's edge locations rather than in a region, so there is no cold-start container and no origin to be far away from.
For a long time Workers was purely compute, and serving a static site through it meant Workers Sites - a KV-backed hack that worked but was nobody's favourite. That is gone. Workers now takes an asset directory as a first-class input and serves it from the edge without your code being involved at all.
Worth knowing before you start: Cloudflare now recommends Workers over Pages for new projects. Pages continues to be supported, but Cloudflare has stated that future investment and feature work goes to Workers, and the two are converging into a single experience. If you are choosing today, choose Workers.
How Static Website Hosting Works
A static site is a directory of files. Hosting it means answering three questions:
- Which file does a given URL map to?
- What happens when no file matches?
- Where is the file physically served from?
The classic answer - S3 answers 1 and 2 badly, CloudFront answers 3 - is why that stack needs a distribution, an origin access control, a default root object, and custom error responses before a site works properly.
Workers answers all three in one config block, because asset routing is built into the platform rather than assembled from two services that were not designed together.
Why Use Cloudflare Workers for Static Websites?
Four reasons, in the order they actually matter:
- Asset requests are not billed. Your traffic bill for a purely static site is zero at any volume.
- No invalidation step. Each deploy is a new immutable version. There is no cache to purge and no per-path invalidation charge.
- One config file.
wrangler.jsoncreplaces a bucket policy, an OAC, a distribution config and a set of error mappings. - Compute is available when you need it. You do not need it on day one. On the day you need an auth check or an API route, it is one file away rather than a Lambda@Edge project.
Architecture
The request path is short:
Browser
→ Cloudflare edge (nearest PoP)
→ asset manifest lookup for the deployed version
├─ match → serve file (edge-cached, no Worker invoked)
└─ no match → not_found_handling, or your Worker script
The important detail is the middle branch. When a request matches a file, your
Worker script does not run. No invocation, no CPU time, no billing. The Worker
only enters the picture when nothing matched - or when you explicitly ask for it
with run_worker_first.
For a pure static site you can skip the Worker script entirely. There is no
main entry, no code, just a directory.
Creating a Cloudflare Worker
You need Node and a Cloudflare account. Nothing else.
npm create cloudflare@latest my-site
cd my-site
npm install -g wrangler # or use npx wrangler
wrangler login
For an existing build output you can skip scaffolding altogether and deploy the directory directly:
npx wrangler deploy --assets=./dist
That is genuinely the whole thing for a site that needs no configuration. For anything real, write the config file.
Deploying a Static Website
Build your site, then point assets.directory at the output:
// wrangler.jsonc
{
"name": "my-site",
"compatibility_date": "2026-09-02",
"assets": {
"directory": "./dist/"
}
}
npm run build
npx wrangler deploy
Wrangler uploads only files whose hashes changed, so the second deploy of a large
site is much faster than the first. You get a *.workers.dev URL immediately.
Note there is no main key above. Without one, this is a static host and nothing
else - which is the correct configuration for most sites and the cheapest one to
reason about.
Handling Static Assets
Two behaviours are worth configuring deliberately.
URL shape. html_handling decides how paths map to .html files. The
default, auto-trailing-slash, serves /about from about.html and redirects
/folder to /folder/ with a 307 to serve folder/index.html. If your
generator emits directory-style output, force-trailing-slash is more
predictable; drop-trailing-slash does the inverse; none disables the magic
and serves exact matches only.
Pick one and match it to whatever your <link rel="canonical"> tags say. A site
that canonicalises to /about/ while the host 307s to /about is generating
redirect chains for every crawler that visits.
Headers and redirects. _headers and _redirects files work exactly as they
did on Pages - drop them in the asset directory and Workers parses them rather
than serving them. Redirects are applied before headers, so when a path matches
both, the redirect wins.
# _headers
/assets/*
Cache-Control: public, max-age=31536000, immutable
# _redirects
/old-post/ /new-post/ 301
This is where you put the long-lived cache headers on hashed asset filenames, and the security headers you would otherwise be adding through a CloudFront response headers policy.
SPA Routing
A single-page app needs every unmatched path to return index.html so the
client-side router can take over. One line:
{
"name": "my-spa",
"compatibility_date": "2026-09-02",
"assets": {
"directory": "./dist/",
"not_found_handling": "single-page-application"
}
}
Unmatched requests now get /index.html with a 200, not a 404. For a static
site generator with real 404 content, use "404-page" instead and Workers serves
your 404.html with a proper 404 status.
Get this right. single-page-application on a content site means every typo URL
returns 200 with your homepage, and search engines will happily index a few
thousand duplicate pages for you.
Custom Domain
In the Workers dashboard: Settings → Domains & Routes → Add → Custom domain. The domain must be on a Cloudflare zone in the same account; Cloudflare creates the DNS record and issues the certificate.
This is the step that has no S3 equivalent - no ACM certificate in us-east-1,
no distribution alternate domain name, no waiting for propagation. If the zone is
already on Cloudflare it takes under a minute.
Cache and Performance
Assets are cached at the edge PoP that served them, automatically, on first request. There is no cache configuration to write for the common case.
The part worth understanding is invalidation, because it does not exist. Each
wrangler deploy creates a new immutable version with its own asset manifest;
requests after the deploy resolve against the new manifest. There is no purge
step, no propagation wait, and no per-path charge - which is a meaningfully
different operational story from CloudFront, where 1,000 invalidation paths per
month are free and $0.005 each after that.
For your own caching, the _headers rules above are the whole toolkit: long
max-age with immutable on content-hashed filenames, short or no-cache on
HTML.
Environment Variables and Configuration
Worth being blunt about a common confusion: environment variables on a Worker are server-side. They are available to Worker code at runtime. They are not available to a static bundle, because that bundle was built before it was uploaded.
{
"vars": { "API_BASE": "https://api.example.com" }
}
npx wrangler secret put SOME_TOKEN # encrypted, not in the config file
If your React app needs VITE_API_URL, that value is baked in at build time by
your bundler - set it in your CI environment, not in wrangler.jsonc. Workers
vars only help you once you have a Worker script actually running.
And the obvious one: vars live in a file you commit. Anything sensitive goes
through wrangler secret put or your CI's secret store, never the config.
CI/CD with GitHub Actions
Cloudflare's own action does the deploy:
name: Deploy Worker
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Create the token from the Edit Cloudflare Workers template and scope it to the single account you deploy to. Cloudflare's own guidance is explicit that this token grants deploy access to your account and must never be committed.
Cloudflare also offers Workers Builds, which connects the repo directly and skips the Action entirely. Use that if you have no other CI; use the Action if your pipeline already does lint, typecheck and tests before deploying - which it should.
Cloudflare Workers vs S3 + CloudFront
The comparison people expect is about performance. It is not; both are fast. It is about billing shape and operational surface.
| Workers + Assets | S3 + CloudFront | |
|---|---|---|
| Egress | Free, unlimited | $0.085/GB NA, $0.120/GB Singapore, after 1 TB free |
| Storage | Included | $0.023/GB-month (S3 Standard) |
| Requests | Free for assets | GET $0.0004/1,000 at S3; CloudFront requests billed separately |
| Origin→CDN transfer | N/A | Free (S3 to CloudFront) |
| Invalidation | No such concept | 1,000 paths/month free, then $0.005/path |
| SPA fallback | One config key | Custom error response mapping 403/404 → /index.html 200 |
| Custom domain | Add domain, done | ACM cert in us-east-1 + alternate domain name |
| TLS | Automatic | ACM, manual attach |
| Pieces to configure | 1 file | Bucket, bucket policy, OAC, distribution, behaviours, error pages |
The AWS side is not badly designed - it is composable, and every piece exists because someone needed it separately. But for the specific job of "serve this folder", you are assembling four services to do what one config key does.
Where S3 + CloudFront still wins outright: you are already on AWS. If the origin is an ALB, if artefacts land in S3 from an existing pipeline, if IAM is how your organisation grants access, then adding a second cloud vendor to save a few dollars of egress is a bad trade. Regional egress rates matter more the more Asia-Pacific traffic you have - the region column in the CloudFront and Cloudflare comparison is worth reading before you model this.
Limitations and Things to Consider
The free-and-unlimited claim has edges. These are the ones that bite:
File count. 20,000 assets per version on Free, 100,000 on Paid. A blog will never notice. A documentation site with a page per API symbol, or an image gallery, absolutely will.
File size. 25 MiB per asset, both plans. Video does not go here. Put large media in R2 and reference it.
run_worker_first changes the billing. Requests matching those patterns
invoke the Worker and count against your quota - that is the point of the flag,
but it means an over-broad pattern quietly converts free asset serving into
metered invocations.
Free-tier exhaustion fails closed. If you exceed Free plan request limits
while using run_worker_first, matching requests get a 429 rather than falling
back to serving the static asset. Something that would have been free is now an
outage.
One vendor for DNS, CDN and hosting. Cloudflare being in the path of everything is the reason it works this well. It is also concentration risk, and worth naming rather than discovering during an incident.
Worker bundle limit is 64 MiB uncompressed if you do add a script. Not a constraint for static hosting; a constraint for large SSR frameworks.
Cost Considerations
For a purely static site the honest number is $0, at any traffic volume, on the Free plan. Asset requests are not billed, storage is not billed, and there is no egress charge. That is not a trial tier - it is the pricing.
You start paying when you add compute:
| Free | Paid ($5/month) | |
|---|---|---|
| Worker requests | 100,000/day | 10 million/month, then $0.30/million |
| CPU time | 10 ms per invocation | 30 million CPU-ms/month, then $0.02/million |
| Static assets | Free, unlimited | Free, unlimited |
| Files per version | 20,000 | 100,000 |
For comparison, the same site on S3 + CloudFront sits inside CloudFront's always-free 1 TB and 10 million requests per month, so a small site is free there too. The difference shows up above that line - and on the operational side, every month, regardless of traffic.
When Should You Use Cloudflare Workers?
Use it for a static site or SPA that is not already living inside an AWS account; when you want deploys with no invalidation step; when your DNS is already on Cloudflare; when you might want an API route later and do not want to migrate to get one.
Do not use it when your assets exceed the file count or size limits, when your origin is an AWS service and the CDN needs to sit in front of it, when organisational policy puts everything behind IAM, or when concentrating DNS, CDN and hosting with one vendor is a risk you are not allowed to take.
Conclusion
Workers with static assets is not a lighter-weight alternative to S3 + CloudFront. For serving a folder of files it is a straightforwardly better tool: fewer moving parts, no invalidation, and a bill that does not scale with success.
The reason to stay on S3 + CloudFront is not that it hosts static sites better. It is that it is already there, next to the rest of your infrastructure - and that remains a completely good reason.
Cover photo by Alina Grubnyak on Unsplash.



