How this site is built
This site is a project too. Here is how it works, what it protects, what it stores and what it costs, read from the code as it is today.
It is live on Cloudflare's free plan. What this page says comes from the code and its tests, not from traffic numbers.
Architecture
One Worker, one deploy. Astro renders every page to static HTML at build time, and Cloudflare's asset layer serves the pages, fonts and images. Those requests never run any code, and Cloudflare serves static assets free and without limit, so the pages stay up even if the Worker's daily quota runs out.
Code runs only where it has to:
- POST /api/contact
- A contact form endpoint: it stores a message, then emails me. The form itself is off the page for now, so nothing calls it.
- POST /api/hit
- The page-view counter.
- GET /api/github
- My pull requests, from a snapshot that a cron trigger refreshes every six hours, so no page view waits on GitHub.
- GET /api/admin/stats and /api/admin/messages
- My private view of the counts and messages. Switched off until Cloudflare Access is in front of it.
- GET /resume
- The resume PDF, counted as a download.
Any other path under /api/ gets a JSON 404. Everything else is a static file or the 404 page.
A browser's requests reach Cloudflare's network. Pages, fonts and images come from the asset layer and never run code. Only /api/* and /resume reach the Worker, and the admin routes stay switched off until Cloudflare Access is in front of them. Only the Worker uses what sits behind it: D1 for contact messages and daily view counts, KV for the GitHub snapshot, an email binding that can send to one verified address, and the rate limits and Turnstile check on the contact endpoint. A cron trigger refreshes the GitHub snapshot every six hours.
One Worker. Static pages never touch it.
Drawn from wrangler.jsonc and worker/index.ts in this site's repository.
Security
A strict Content-Security-Policy
Astro hashes every script and style it emits and writes the policy into each page, so the policy never has to allow inline code, and an injected inline script or style doesn't run. The HTML ships with no style attributes or inline event handlers, and images, fonts and fetches are limited to this site.
Headers on every page
HSTS for two years, including subdomains. No framing by any other site. No MIME sniffing. A strict referrer policy. The camera, microphone, location, payment, USB and ad-topic APIs turned off. And a cross-origin opener policy, so a page on another site that opens this one gets no handle to it.
The contact endpoint
A message has to come from this site's own origin, be JSON under 8 KB, counted on the bytes actually read, and pass validation on every field. Then come a limit of 5 messages a minute per IP address and a Cloudflare Turnstile check that fails closed if Turnstile is down. A hidden honeypot field drops bots without telling them.
Parameterized queries
Every value from a request reaches the database as a bound parameter, never as SQL text. A test sends hostile text and checks that it is stored exactly as sent.
Two locks on the admin API
It is built for Cloudflare Access to guard it at the edge, and for the Worker to check Access's signed token again: RS256 only, signed by a key Access publishes, then the audience, issuer, expiry and my email. Until Access is configured the admin API is off, not open, and that is how it runs today.
Email that can't become a relay
The Worker's email binding has exactly one destination, my own verified address, so even a bug in the contact route can't send mail anywhere else. The reply-to has to be a bare address, and a name or company with a line break in it is flattened to one line.
Errors that say nothing
Every error body is a fixed code, such as rate_limited. The details go to the log, never to the visitor. Secrets are set with wrangler and never kept in the repository.
Privacy
No cookies, and no analytics service. The page-view counter keeps one number per day for each combination of page, referring site, country and network (an internet provider or a university, say), and nothing else: no IP address, no visitor ID, and no full referrer, only its host name. A network name can be a company's, so a row can show that someone at that company opened a page. It never shows who.
A browser's user agent is checked only to leave bots out of the count, and an IP address only to rate limit the counter. Neither is stored.
The contact form is off the page for now. If it comes back, it stores what you send, your name, email, company and message, plus your country and the time it arrived, so I can reply, and emails me a copy. Your IP address is used for the rate limit and the Turnstile check, and is never saved with your message.
Testing
The Worker's tests run inside workerd, the real Workers runtime, against a real local D1 database and KV store. Email, rate limits, Turnstile and GitHub are faked, so nothing talks to the network.
They test the abuse cases as well as the happy path: a missing origin, oversized and malformed bodies, a flood of messages, a Turnstile outage, forged admin tokens from the none algorithm to HS256 to an expired token, and a GitHub outage that must leave the last good snapshot in place.
The content is typed too. A project fact with no source fails the build, and on the case studies any sentence with a number in it needs a source. One command, npm run gate, runs the type checks, the tests and the build.
A second suite drives real browsers through every built page, served by the real Worker: console errors, the security policy, layout from phone to desktop, keyboard order, accessibility, and the rule that nothing is coloured until you hover. Both suites run in CI on every push to main.
Cost
Hosting costs $0 on Cloudflare's free plan, so the domain name is the only bill. Static pages are free and unlimited, and only the routes above ever run code.
Credits
- Archivothe typeface, under the SIL Open Font License
- Lucidethe line icons, under the ISC License
- Simple Iconsthe GitHub mark and the technology logos, under CC0
The code lives in a private repository. I'm happy to walk through it.