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:

author
Zahara Schaefer
published
read
6 min
tested
Wrangler 4.x and Workers static assets (config keys from developers.cloudflare.com, checked 2026-11-02)
outline {
  1. What you need
  2. 1. Install Wrangler and log in
  3. 2. Write the Wrangler config
  4. 3. Deploy
  5. 4. Add your custom domain
  6. 5. Add security and cache headers
  7. 6. Add an API route with a Worker
  8. 7. Keep secrets out of the repo
  9. 8. Turn on logs
  10. 9. Roll back a bad deploy
  11. Troubleshooting
  12. References
  • 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:

bash
npm install --save-dev wrangler
npx wrangler login
npx wrangler whoami

Check it worked: whoami prints your account name and ID.

Step 2: Write the Wrangler config

At the project root, create wrangler.toml:

toml
name = "my-site"
compatibility_date = "2026-11-01"

[assets]
directory = "./dist"
not_found_handling = "404-page"
  • directory is your build output.
  • not_found_handling = "404-page" serves your 404.html for unknown paths. For a single-page app that handles its own routing, use "single-page-application" instead.
  • compatibility_date pins 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:

bash
npm run build
npx wrangler dev

Open the local URL Wrangler prints. Your pages load, and an unknown path shows your 404 page.

Step 3: Deploy

bash
npx wrangler deploy

Wrangler 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:

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:

bash
curl -sI https://example.com | head -n 5

The 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/):

text
/*
  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, immutable

Only 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:

toml
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.

javascriptsrc/worker.js
// 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:

bash
curl -s -X POST https://example.com/api/contact -F email=test@example.com -F message=hi

It 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:

bash
npx wrangler secret put NOTIFY_URL

Wrangler 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:

text
NOTIFY_URL=https://example.invalid/test-hook

Add .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:

toml
[observability]
enabled = true

After 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:

bash
npx wrangler tail

Check 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:

bash
npx wrangler deployments list
npx wrangler rollback

rollback 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

SymptomLikely causeFix
Every path returns the home pagenot_found_handling is set to "single-page-application"Use "404-page" for a normal static site.
/api/... returns a 404 pageThe Worker never runs for that pathAdd the path to run_worker_first, and check main is set.
_headers has no effectFile not in the build output, or the response comes from Worker codeCheck that dist/_headers exists after the build. Set headers in code for Worker responses.
Custom domain fails to attachAn existing DNS record for that hostnameRemove the old record, then deploy again.
env.NOTIFY_URL is undefined locally.dev.vars missingCreate it, then restart wrangler dev.

References