Host a static site on Cloudflare Workers with static assets
Cloudflare Workers can serve a static site straight from Cloudflare's network, and run code next to it when you need a form handler or a small API. This site runs that way. This guide goes from a folder of built HTML to a deployed site on your own domain, with:
outline {
- security headers;
- one API route that uses a secret;
- logs;
- a rollback you've tested.
Cloudflare's docs recommend Workers with static assets for new projects that would previously have gone on Pages, so that's what this guide uses.
What you need
- A Cloudflare account, with your domain's DNS on Cloudflare (needed for a custom domain).
- Node.js (a current LTS release) and npm.
- A site that builds to a folder of static files, for example
dist/. Any generator works: Astro, Vite, Hugo, Eleventy, or plain HTML.
Step 1: Install Wrangler and log in
Wrangler is Cloudflare's CLI. Install it in the project, so everyone uses the same version:
npm install --save-dev wrangler
npx wrangler login
npx wrangler whoamiCheck it worked: whoami prints your account name and ID.
Step 2: Write the Wrangler config
At the project root, create wrangler.toml:
name = "my-site"
compatibility_date = "2026-11-01"
[assets]
directory = "./dist"
not_found_handling = "404-page"directoryis your build output.not_found_handling = "404-page"serves your404.htmlfor unknown paths. For a single-page app that handles its own routing, use"single-page-application"instead.compatibility_datepins the Workers runtime behaviour. Set it to the date you start, and change it deliberately later.
With no main script, this is a static-only deployment: Cloudflare serves the files and no code runs.
If the build folder holds files you don't want published, list them in a .assetsignore file inside dist/. It uses the same syntax as .gitignore.
Check it worked:
npm run build
npx wrangler devOpen the local URL Wrangler prints. Your pages load, and an unknown path shows your 404 page.
Step 3: Deploy
npx wrangler deployWrangler uploads only changed files and prints a *.workers.dev URL.
Check it worked: the workers.dev URL serves your site.
Step 4: Add your custom domain
Add a route to wrangler.toml:
routes = [
{ pattern = "example.com", custom_domain = true },
{ pattern = "www.example.com", custom_domain = true }
]Deploy again. Cloudflare creates the DNS records and the certificate for a custom domain. If a record already exists for that hostname, remove it first, or Wrangler reports a conflict.
To send www to the bare domain (or the other way round), add a redirect rule under Rules > Redirect Rules in the dashboard. Or handle it in code once you have a Worker script (step 6).
Check it worked:
curl -sI https://example.com | head -n 5The response is HTTP/2 200, with headers from Cloudflare.
Step 5: Add security and cache headers
Put a _headers file in your build output (for most generators, that means the public/ folder that gets copied into dist/):
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Frame-Options: DENY
/assets/*
Cache-Control: public, max-age=31536000, immutableOnly use the long immutable cache on files whose names change when their content changes (hashed file names, as most bundlers produce). A Content-Security-Policy is worth adding too, but build it for your own site. A copied policy usually breaks something.
_headers applies to static asset responses only. Responses your Worker code generates (step 6) need their headers set in code.
Check it worked: curl -sI https://example.com/ shows the new headers after you deploy.
Step 6: Add an API route with a Worker
Say you want /api/contact to accept a form post. Add a script and tell Wrangler about it:
name = "my-site"
main = "src/worker.js"
compatibility_date = "2026-11-01"
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
run_worker_first = ["/api/*"]By default, a request that matches a static file is served without running your Worker. run_worker_first sends the listed paths to your code first. The ASSETS binding lets your code fetch static files when it wants to.
// src/worker.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/contact") {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const form = await request.formData();
const email = String(form.get("email") || "").trim();
if (!email.includes("@")) {
return Response.json({ ok: false, error: "invalid email" }, { status: 400 });
}
// Forward to your notification service, using a secret
const res = await fetch(env.NOTIFY_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message: String(form.get("message") || "") }),
});
return Response.json({ ok: res.ok }, { status: res.ok ? 200 : 502 });
}
return env.ASSETS.fetch(request);
},
};For a public form, add spam protection, such as Turnstile, and verify its token on the server before you forward anything.
Check it worked:
curl -s -X POST https://example.com/api/contact -F email=test@example.com -F message=hiIt returns {"ok":true} once the secret from step 7 is set.
Step 7: Keep secrets out of the repo
Store NOTIFY_URL as a secret, not in wrangler.toml:
npx wrangler secret put NOTIFY_URLWrangler prompts for the value, encrypts it, and makes it available as env.NOTIFY_URL. For local development, put the same name in a .dev.vars file:
NOTIFY_URL=https://example.invalid/test-hookAdd .dev.vars to .gitignore.
Check it worked: npx wrangler secret list shows NOTIFY_URL. The list shows names only, never values.
Step 8: Turn on logs
Add to wrangler.toml:
[observability]
enabled = trueAfter the next deploy, logs from your Worker (including console.log lines) appear under Workers & Pages > your Worker > Logs in the dashboard. For a live stream from the terminal:
npx wrangler tailCheck it worked: run the curl from step 6 while wrangler tail is running, and the request appears.
Step 9: Roll back a bad deploy
Every deploy creates a version. List recent ones and roll back:
npx wrangler deployments list
npx wrangler rollbackrollback with no ID goes back to the previous version, and asks you to confirm. You can also pass a specific version ID. Rollback doesn't undo secret changes or other bindings' data, so keep that in mind.
Try it once on purpose, on a quiet day, so you know it works before you need it.
Check it worked: after rolling back, the site serves the previous version, which you can confirm with a visible change such as a footer date.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every path returns the home page | not_found_handling is set to "single-page-application" | Use "404-page" for a normal static site. |
/api/... returns a 404 page | The Worker never runs for that path | Add the path to run_worker_first, and check main is set. |
_headers has no effect | File not in the build output, or the response comes from Worker code | Check that dist/_headers exists after the build. Set headers in code for Worker responses. |
| Custom domain fails to attach | An existing DNS record for that hostname | Remove the old record, then deploy again. |
env.NOTIFY_URL is undefined locally | .dev.vars missing | Create it, then restart wrangler dev. |
References
- Workers static assets: https://developers.cloudflare.com/workers/static-assets/
- Static assets binding and options: https://developers.cloudflare.com/workers/static-assets/binding/
- Wrangler configuration: https://developers.cloudflare.com/workers/wrangler/configuration/
- Custom domains: https://developers.cloudflare.com/workers/configuration/routing/custom-domains/
- Headers file: https://developers.cloudflare.com/workers/static-assets/headers/
- Secrets: https://developers.cloudflare.com/workers/configuration/secrets/
- Workers Logs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
- Rollbacks: https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/