Skip to content
Luminesca.
Journal · Behind the build

Three hostnames, one Cloudflare Pages project: how we route with a Worker

This network runs six sites on four Cloudflare Pages projects, and the split is deliberate. Three hostnames share one project — the apex portal, the games sub-site and the Hollow Knight sub-site — routed by a small Worker that branches on the incoming host header. The other three sub-sites have their own projects, because they share neither the navigation nor the release rhythm. The routing Worker itself is only a few lines of code; three separate Cloudflare behaviours cost far more debugging time than the routing did.

Why bother

Our network started as one site and grew sideways: a game guide hub, a tools collection, a travel library, a news and analysis site, each with its own domain. The obvious structure — one Pages project per domain — works fine, but it also means a build configuration, a deploy command and a place for shared navigation to drift out of sync for every property. When a footer link is wrong, you want to fix it once. This network ended up in the middle: the three hostnames that share a brand, a navigation and a release rhythm sit in one project, and the three that do not have their own.

Cloudflare Pages lets you attach custom domains to a project, but every attached domain receives the same files. To serve different content per hostname you need a Worker in front of it, which Pages supports natively through a _worker.js file at the project root.

The routing itself

The whole mechanism is a fetch handler that reads the host and rewrites the request path before falling through to the static assets:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const host = request.headers.get("host") || "";
    let path = url.pathname;

    if (host === "luminesca.net" || host === "www.luminesca.net") {
      path = "/main" + (path === "/" ? "/index.html" : path);
    }
    url.pathname = path;
    return env.ASSETS.fetch(new Request(url, request));
  }
};

What you see above is the shape of the routing, simplified for the page — the production file also issues 301s for legacy paths that moved between sub-domains and sets cache headers per asset type. The core is exactly as short as it looks: everything under /main/ belongs to the apex domain, the project root belongs to the games sub-site, and paths under /hollow-knight/ belong to the Hollow Knight sub-site. One build, one deploy, three hostnames. Cloudflare documents the pattern under Pages Functions advanced mode, and the asset binding behaviour is described in the Functions API reference.

Trap one: clean URLs silently rewrite your paths

Pages applies "clean URL" handling by default: a request for /pages/kyoto.html returns a 308 redirect to /pages/kyoto. This is usually what you want, and it is also quietly incompatible with two things you are almost certainly doing elsewhere.

First, canonical tags. If your canonical says https://example.com/pages/kyoto.html but the crawler was redirected to the extensionless form, you have told search engines that two URLs are the same page while serving one of them as a redirect. We now write every canonical and every sitemap entry in extensionless form. Second, relative links — though the popular explanation of this one is wrong, including our own first attempt at it. A page at /pages/kyoto.html and the same page at /pages/kyoto resolve images/x.jpg identically: both against /pages/, giving /pages/images/x.jpg. It is the trailing-slash form, /pages/kyoto/, that behaves differently and resolves to /pages/kyoto/images/x.jpg. The breakage we actually hit came from nesting depth rather than from the extension: generated pages one directory deeper, such as /tech/page/2, carried image paths written for the level above and resolved one directory too deep. The rule we settled on is blunt but reliable — absolute URLs in canonical, sitemap and Open Graph tags; relative paths only inside a page, and only after resolving which directory they land in.

Trap two: the four-hour CSS cache that looks like a failed deploy

When we first hit this, a changed stylesheet appeared to sit cached at the edge for roughly four hours, so a redeploy looked like a failed deploy. We lost real time to it twice before working out the diagnosis. The project no longer relies on guessing what the defaults are: the Worker now sets Cache-Control explicitly per asset type. Measured on 11 September 2026, css/main.css?v=20260908a returns public, max-age=31536000, immutable; an unversioned image returns public, max-age=86400, stale-while-revalidate=604800; and HTML returns public, max-age=0, must-revalidate. Record the header your own site actually returns rather than trusting any published default — ours changed.

The mistake that makes it worse is verifying with a cache-busting query string: style.css?v=2 returns the new file, which looks like proof the deploy worked, while a real visitor or crawler may still be receiving a previously cached copy. The only dependable approach is versioned references in the HTML itself — style.css?v=20260907a — bumped in the same commit that changes the CSS. It is mechanical, so a short script that rewrites the version across all templates is worth writing on day one.

Trap three: concurrent writes to the same file

This one is not Cloudflare's fault. While preparing a set of changes to a single HTML file, several edits were issued in parallel and overwrote each other, so the deployed page contained half the intended changes. The file on disk looked correct right up until we checked it against the live site.

The fix is procedural rather than technical: make multiple edits to one file sequentially, and grep the file for every expected change before deploying, not after. It costs seconds and it has since caught two more partial writes that would otherwise have shipped.

When not to do this

The pattern has real costs. Every deploy republishes every domain, so a mistake in the games site can take down the apex domain with it. Assets are shared, which means a large media directory slows deploys for sites that do not use it. And if two of your properties ever need genuinely different build tooling — a framework with a compile step alongside a folder of hand-written HTML — you will fight the shared pipeline rather than benefit from it.

The test we use: if the sites share a brand, a navigation and a deployment rhythm, one project is simpler. If they have independent release cadences or different build stacks, separate projects are worth the overhead. That is not theory here — it is the line this network actually drew. Anything performance-related on the front-end is worth reading about separately, and our web performance notes cover the measurement side in more detail.

Deploying when someone else is editing too

A hazard specific to this setup: because one project serves several domains, a deploy republishes everything — including directories another person or another process is working on. We have had a deploy carry someone else's half-finished edit to production because both of us were writing to the same project without knowing it.

Two habits reduced this to a non-issue. Before deploying, list files by modification time and look for anything touched in the last few minutes that you did not touch yourself. Then grep the file you changed for each expected edit, and confirm every one is present on disk — not in your editor, on disk. It takes under a minute and it has caught partial writes that would otherwise have gone live.

The inverse is also worth remembering: because deploys are all-or-nothing, you cannot ship a fix to one domain without also shipping whatever state the others happen to be in. If two properties have genuinely independent release needs, that is the signal to split the project rather than work around it.