We run six public websites on Cloudflare Workers. One is this site, the studio site. The other five belong to our games and apps: a page for Mahjong Moonwindow, one for Ten Million Cells, one for Scrapheap, one for our music player Playwave, and one for a puzzle game called Connect Garden.
None of them is complicated. They're plain HTML, a stylesheet, some images, maybe a short video. We used to think of hosting like this as a solved problem, and in a sense it is. But when you have six of them, and one of them handles a contact form with real secrets, the small decisions start to matter. Which site lives in which Worker? What gets cached for how long? What happens on a 404? How do search engines find out you changed something?
This post is how we answered those questions, and why.
Why Workers and not Pages
Cloudflare has two ways to host a static site: Pages, and Workers with static assets. Cloudflare's own guidance has been moving toward Workers for new projects, and that's where we are too. Two things decided it for us.
First, all our backends already run on Workers. Our game APIs, our cloud-save service, our OTA update server and our chat app are all Workers, deployed with Wrangler. Having the websites deploy the same way means one tool, one config format, one mental model. When something looks odd on a site, we already know where to look.
Second, a static-assets Worker can grow a script later without changing platforms. Five of our six sites have no code at all. The studio site has a small script for a contact form and for video. Both kinds deploy the same way. If a game site one day needs an endpoint, we add a main file to its config and redeploy. Nothing moves.
There's also the price. Requests that are served straight from static assets don't invoke a Worker and aren't billed as Worker requests, on the free plan or on Paid. A site that's only assets is effectively free to serve, however much traffic it gets. The limits that do apply are about the files: on the free plan, a Worker can have up to 20,000 asset files, and each file can be up to 25 MiB. Our biggest site has a few hundred files. We aren't close.
One Worker per hostname
The most useful decision we made was the simplest: every site is its own Worker, and every Worker owns exactly one hostname (the studio site also answers on www).
When we added the game pages, the tempting option was to put them in the studio site's Worker. It was already deployed, the domain was already there, and four extra pages felt like nothing. We decided against it, and wrote the reason at the top of each game site's config:
# A SEPARATE Worker from `thenextbeacon` on purpose. The studio site is live and
# carries the contact form's SMTP secrets; re-deploying it to add four static
# pages would put the marketing site at risk for no benefit. This one is
# assets-only -- no script, nothing to break -- and owns exactly one hostname.
name = "tenmillioncells-site"
compatibility_date = "2026-06-01"
workers_dev = false
assets = { directory = "./public" }
routes = [
{ pattern = "tenmillioncells.thenextbeacon.com", custom_domain = true },
]
That's the whole config for one of our game sites. There's no main, so there's no script. There's nothing to throw an exception, nothing to time out, and no secret to leak. Changing the game's store listing page means redeploying a Worker that contains only HTML. The studio site, with its contact form and its mail credentials, isn't touched.
It also keeps the blast radius honest in the other direction. If we break the studio site's script, the game pages keep working. Each site can be deployed, rolled back or deleted without thinking about the others.
The custom_domain = true route is the other half of this. When you deploy a Worker with a custom domain route, Cloudflare creates the DNS record and the certificate for that hostname and keeps them attached to the Worker. There's no separate DNS step, no proxy record to remember, and no way for the DNS and the Worker to drift apart. We like that a lot. A setup step you don't have is a setup step you can't forget.
On the game sites, workers_dev = false turns off the free *.workers.dev address. For a static site it doesn't matter much, but there's no reason to serve the same site on two hostnames, and it's one less URL for a search engine to find as a duplicate.
Don't share a hostname with your API
One rule we wrote down came from the games, and it's worth repeating to anyone who ships an app. The Ten Million Cells site is at tenmillioncells.thenextbeacon.com. The game's API is on a different hostname, and the marketing site will never live there.
The reason is in the config comment too. The API hostname is baked into every copy of the game that's been installed on a phone. You can't change it without shipping an app update, and you can't take it down without breaking every player who hasn't updated. If the marketing pages lived on that hostname, a change to a store listing page would mean redeploying the Worker that phones in the wild depend on. We'd rather never be in that position. Marketing changes weekly. An API hostname is for ever.
Cache rules in a _headers file
Workers static assets supports a _headers file, in the same format Cloudflare Pages uses. It sits in the asset directory and attaches headers to paths. Ours, for the studio site, is short:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Strict-Transport-Security: max-age=31536000
/media/*
Cache-Control: public, max-age=31536000, immutable
/og/*
Cache-Control: public, max-age=86400
/fonts/*
Cache-Control: public, max-age=2592000
The security headers apply to everything. A static site doesn't have much to protect, but these cost nothing, and a contact form on the same origin is a reason to have them.
The cache rules are where the thinking goes. Files in /media/ are named by a hash of their contents, so a file at a given URL never changes. When we re-render a video, it gets a new name and the page points at the new name. That makes it safe to tell browsers to keep those files for a year and never check again, which is what immutable means.
Open Graph images in /og/ are the pictures that show up when somebody shares a page on LinkedIn or in a chat app. Those keep their names when we regenerate them, so they get a day. If we change a post's title and the image is regenerated, the old one is gone from browser caches by tomorrow. Social networks keep their own copies on their own schedule anyway, so a longer cache wouldn't buy us much.
Fonts get thirty days. They almost never change, but they aren't hash-named, so a year felt too confident.
And HTML gets no rule at all, which means Cloudflare's default behaviour for static assets. Pages are revalidated, so a deploy shows up straight away. We wouldn't want it any other way. The one file you must never cache for a long time is the page that tells the browser which other files to load.
404 pages that are real 404s
Static assets has a setting for what to do when a path doesn't match a file:
assets = { directory = "./public", not_found_handling = "404-page" }
With 404-page, Cloudflare serves the nearest 404.html with a real 404 status. Without it, a missing path returns an empty 404. The other option, single-page-application, serves index.html with a 200 for every unknown path, which is what a client-side router wants and exactly what a content site doesn't.
That status code matters more than it looks. A missing page that returns a 200 with your homepage is what search engines call a soft 404. Google will eventually work it out, but in the meantime it can index garbage URLs as copies of your homepage. Three of our sites use 404-page. The other three are game sites with only a handful of pages, and they don't have it yet. They should.
When a static site needs a script
The studio site is the one with a script. It does two jobs.
The first is the contact form. A static site can't send email, so the form posts to /api/contact, which is handled by the Worker. The Worker checks a Turnstile token on the server, then sends the message over SMTP using a raw TCP socket from cloudflare:sockets. That's a story of its own, and we wrote it up in sending SMTP email from a Cloudflare Worker. The SMTP password and the Turnstile secret are Worker secrets. If anything in the mail path isn't configured, the endpoint returns a 501 and the page falls back to a plain email link, so a broken mail setup never turns into a form that swallows messages.
The second job is video. The homepage and the work pages have short looping videos. Safari on iPhone won't play a video unless the server answers HTTP range requests with a partial response, and static assets answers a range request with the whole file. So the config sends /media/* through the script first:
assets = { directory = "./public", binding = "ASSETS", run_worker_first = ["/media/*"], not_found_handling = "404-page" }
run_worker_first takes a list of path patterns. For those paths, the Worker runs before the asset lookup, fetches the file through the ASSETS binding, and slices it into a 206 response. Everything else is still served straight from static assets, with no script involved and nothing billed. We covered that in detail in range requests for video on Cloudflare Workers.
We like that this is opt-in per path. The script only runs where it's needed. The other 99% of requests never wake it.
A build that refuses to ship broken links
Our sites are generated by small build scripts. There's no framework. For the studio site it's one Node script that turns page modules and Markdown posts into plain HTML, and writes a sitemap, an RSS feed and an llms.txt alongside them.
Because there's no framework, the build can afford to be strict, and it is. It stops the deploy if:
- two pages would be written to the same path,
- a page has no title or no description,
- a page says it has a translation in another language and that page doesn't link back,
- or any internal link points at something that doesn't exist.
The last check is the one that's saved us most. After writing every page, the build reads each HTML file back, finds every href that starts with /, and checks it against the list of pages and the files in the output directory:
const known = new Set(pages.map((p) => p.path));
let broken = 0;
for (const file of written) {
const html = fs.readFileSync(file, "utf8");
for (const [, href] of html.matchAll(/href="(\/[^"#?]*)/g)) {
if (known.has(href) || known.has(href + "/")) continue;
if (fs.existsSync(path.join(PUB, href)) && fs.statSync(path.join(PUB, href)).isFile()) continue;
console.error(`broken link in ${path.relative(PUB, file)}: ${href}`);
broken++;
}
}
if (broken) process.exit(1);
It's twelve lines. It catches renamed posts, typos in a slug, a link to a page we meant to write and didn't. Every post in this series links to others, and the build won't let us publish one that points at a post that doesn't exist yet. That's an inconvenience on purpose.
The hreflang check is there for the same reason. We have a small set of hand-written Dutch pages, and if an English page claims a Dutch twin, both have to point at each other. Search engines ignore one-way hreflang, and a broken pair is easy to miss by eye.
Telling search engines with IndexNow
When a new post goes up, we'd like search engines to know today, not whenever they next crawl. Google has its own process, through Search Console. For most of the others there's IndexNow.
IndexNow is a simple protocol: you host a key file on your site to prove you own it, then POST a list of URLs to one endpoint, and the participating engines share it. Bing, Yandex, Seznam and Naver all take part. Bing matters most to us, because Bing's index also feeds ChatGPT search, Copilot and DuckDuckGo.
Our deploy script runs it after every deploy:
"deploy": "npm run build && wrangler deploy && node site/indexnow.mjs"
The script reads the sitemap the build just wrote, pulls out every URL, and posts them all:
const urlList = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
const res = await fetch("https://api.indexnow.org/indexnow", {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ host: HOST, key: KEY, keyLocation: `https://${HOST}/${KEY}.txt`, urlList }),
});
console.log(`indexnow: ${res.status} for ${urlList.length} urls`);
The key isn't a secret. It's meant to be public, and it's in the site's root as a text file. The only thing it proves is that whoever submits URLs for the domain can also put a file on it.
Sending the whole sitemap every time is a little lazy. At our size the protocol is happy with it, and it means we never have to work out which pages changed. On a much bigger site, sending only the changed URLs would be the polite thing to do.
What we'd tell someone starting out
If you're putting a static site on Cloudflare today, use Workers with static assets. Give each site its own Worker and its own hostname, and attach the hostname with a custom domain route so the DNS is managed for you. Keep sites without a script free of a script; it's the most reliable code there is. Put cache rules in _headers: a year for hash-named files, much less for anything that keeps its name, and nothing for HTML. Turn on 404-page so missing pages are real 404s. Use run_worker_first for the few paths that need code, and leave the rest alone.
And make your build strict. A link checker is a few lines, and it's the difference between a site that slowly fills with dead links and one that can't.
If you're curious what the limits look like when your Workers do have code, we wrote about the Workers free plan limits, and about choosing between D1, KV, Durable Objects and R2 once a site starts needing data.