Shipping a static site on Cloudflare Workers
How we run this static site on Cloudflare Workers: static assets, run_worker_first for a www redirect, clean URLs, a 404 page, _headers for CSP and caching, and a tiny Worker for forms.
This site is a static site on Cloudflare Workers. Plain HTML files, one stylesheet, one small script, a self-hosted font, and a Worker of under two hundred lines that handles the things static files cannot. There is no framework and no server we have to patch. We think it is a good setup for a small studio site, so here is how it fits together, including the parts that took us a couple of tries.
Static files first
The pages are written by hand and built by a small Python script into a public/ folder. Every page, image and stylesheet in that folder is served by Workers static assets. Deploying is one command, wrangler deploy, which uploads only the files that changed and publishes the Worker alongside them.
The config lives in wrangler.jsonc. The relevant block looks like this:
"assets": {
"directory": "./public",
"binding": "ASSETS",
"run_worker_first": true,
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page"
}
Both hostnames, humanly.is and www.humanly.is, are attached as custom domains in the same file, so the deploy creates the DNS records and certificates itself. The only thing that has to exist beforehand is the zone on the Cloudflare account.
Why the Worker runs first
By default, the assets service answers any request that matches a file before your Worker code sees it. That is fast and usually what you want. It was not what we wanted, because we need one canonical hostname. Without intervention, www.humanly.is/desktop-apps would happily serve the same page as humanly.is/desktop-apps, and search engines would see two copies of the site.
Setting run_worker_first to true flips the order. Every request goes through the Worker, which checks the hostname and returns a 308 redirect to the apex for anything on www. We use 308 rather than 301 because it keeps the method and body, so a stray form post to the old host does not silently turn into a GET. The path and query string carry over unchanged.
The Worker also holds a short map of legacy paths from an older version of the site and returns a 301 for those. Anything else falls through to one line:
return env.ASSETS.fetch(request);
The cost of running the Worker first is a small amount of CPU on every request. For a site this size it is not measurable in practice, and it keeps all routing in one readable file instead of split between config and code.
Clean URLs and a real 404
Internal links on the site are extensionless, like /desktop-apps, while the files on disk are desktop-apps.html. The html_handling option decides how the assets service maps between the two. With drop-trailing-slash, a request for /desktop-apps serves the HTML file, and a request for /desktop-apps.html gets redirected to the clean path. Only one URL per page ever returns a 200, which keeps canonical tags and the sitemap honest.
not_found_handling set to 404-page tells the assets service to serve our 404.html with a proper 404 status for any path that does not match a file. The other option, single-page-application, would serve the index page for every unknown path, which suits a client-side router but would make every typo look like a working page to crawlers. For a content site you want real 404s.
Headers without a server
Security and cache headers go in a plain text file, public/_headers. The assets service applies it to the files it serves. It does not apply to responses the Worker builds itself, such as the JSON from the form endpoints, so those set their own cache-control: no-store in code. The headers documentation covers the syntax and the matching rules.
Every page gets the same set: Strict-Transport-Security with a one-year max-age, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a strict referrer policy, a Permissions-Policy that turns off geolocation, camera, microphone and payment, and a Content Security Policy.
The CSP starts from default-src 'none' and adds back only what the site uses. Scripts, styles, fonts and images come from our own origin. The only third party is Turnstile, which needs challenges.cloudflare.com for its script and its iframe. There is no 'unsafe-inline' for scripts or styles.
That last rule has consequences you only discover later. No inline style attributes anywhere, including inside SVG. Every illustration on this journal, including the one above, is drawn with presentation attributes like fill and stroke instead of CSS, because a single style="" would be blocked and the drawing would render wrong. It is a small discipline, and it keeps the policy tight.
Caching is set per path in the same file. The font is cached for a year and marked immutable. The stylesheet and script get an hour with must-revalidate, because they change when we edit the site and we have not bothered with hashed filenames. Journal images get a week. HTML pages use the platform defaults, which revalidate, so a fix to a page shows up straight away.
A tiny Worker for forms
The site has two forms: a contact form and a project brief for custom development. Both post JSON to the Worker at /api/contact and /api/project. The handler does the boring things carefully:
- Rejects bodies over 32 KB before parsing them.
- Trims and length-limits every field.
- Accepts only known values for the multiple-choice fields, and drops anything else rather than echoing it into the email.
- Checks a hidden honeypot field. If a bot fills it, the Worker returns success and does nothing.
- Verifies a Turnstile token with the siteverify endpoint before sending anything.
The Turnstile check is written to fail closed. If the secret is missing, verification fails, unless a development-only variable is set in the git-ignored local vars file used by wrangler dev. We have seen form handlers that skip verification when the secret is unset, which works fine until a deploy loses the secret and the form quietly waves every bot through. The public site key is served from a small /api/config endpoint rather than baked into the HTML, so it can change without a rebuild.
When everything checks out, the Worker sends a plain text email through Cloudflare's email sending binding, with the visitor's address as reply-to so we can answer directly from our inbox. If sending fails, the visitor sees an error with our email address, and the failure is logged. Workers observability is switched on, so those logs are there when we need them without adding a logging service.
The font, and what we would change
The site uses one typeface, Kode Mono, as a single variable woff2 file of about 15 KB, subset to Latin. We used to load it from Google Fonts. It worked most of the time, but when the external request was slow or blocked, the site fell back to the system monospace font and stopped looking like itself. Self-hosting fixed that, removed a third-party request, and let us tighten font-src to our own origin.
The setup is not perfect. HTML pages rely on default caching, which is fine for our traffic but not tuned. The stylesheet would be better with a content hash in the filename and a year-long cache. And the build script is ours, which means it is also ours to maintain. We still prefer that to a framework upgrade every few months.
If you are putting a small site on Workers, the three settings we would copy are run_worker_first if you need any host or path logic, 404-page so missing pages return a real 404, and a _headers file with a CSP that starts from 'none'. We would also copy the fail-closed Turnstile check, because it is the part that breaks silently.