Asia VPNAsiaVPNGet app
All posts

Hosting Static Websites on Cloudflare Workers: A Practical Guide

8 min read

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:

  1. Which file does a given URL map to?
  2. What happens when no file matches?
  3. 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.jsonc replaces 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 + AssetsS3 + CloudFront
EgressFree, unlimited$0.085/GB NA, $0.120/GB Singapore, after 1 TB free
StorageIncluded$0.023/GB-month (S3 Standard)
RequestsFree for assetsGET $0.0004/1,000 at S3; CloudFront requests billed separately
Origin→CDN transferN/AFree (S3 to CloudFront)
InvalidationNo such concept1,000 paths/month free, then $0.005/path
SPA fallbackOne config keyCustom error response mapping 403/404 → /index.html 200
Custom domainAdd domain, doneACM cert in us-east-1 + alternate domain name
TLSAutomaticACM, manual attach
Pieces to configure1 fileBucket, 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:

FreePaid ($5/month)
Worker requests100,000/day10 million/month, then $0.30/million
CPU time10 ms per invocation30 million CPU-ms/month, then $0.02/million
Static assetsFree, unlimitedFree, unlimited
Files per version20,000100,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.

Keep reading