This site has moved a lot. It started back in high school as plain HTML on GitHub Pages, got a Jekyll theme a year later, then became a Dart 1 app talking to Firebase (the package was called fckng_portfolio, don't judge), then a Hugo blog, and now it's Jaspr11 More about Jaspr in a future post..

1 More about Jaspr in a future post.

Today all of it runs on Cloudflare: the domain, DNS, the site, the comments and the email. And I pay for exactly one thing, the domain.

It's not just this blog. Scratches Included (opens in a new tab), aurasalon.cz (opens in a new tab) and a few more sites I look after for friends run the same way, on Jaspr and Cloudflare.

So here's the whole setup, written down once.

/api, /admin

reader

Cloudflare

static files

comments Worker

D1

email to tenhobi.dev

Email Routing

Gmail

The domain

I buy domains at Cloudflare Registrar. Both tenhobi.dev and Scratches Included (opens in a new tab) are there.

The reason is boring and that's the point: Cloudflare sells domains at cost. No first-year discount that triples on renewal, no paid "privacy protection", no upsells at checkout. You pay what the registry charges plus the ICANN fee, the same every year.22 .dev is a Google Registry TLD and sits on the HSTS preload list, so every .dev site has to be HTTPS. With Cloudflare in front you never think about it.

2 .dev is a Google Registry TLD and sits on the HSTS preload list, so every .dev site has to be HTTPS. With Cloudflare in front you never think about it.

The catch is that a domain registered at Cloudflare has to use Cloudflare DNS. For me that's fine, I'd move the DNS there anyway. But if you want your registrar and your DNS at two different companies, this isn't for you.

Hosting: Pages, then Workers

The Hugo version ran on Cloudflare Pages. You connect a GitHub repository, Pages sees a Hugo site, builds it on every push and gives every branch its own preview URL. No CI to write. I even had a small CMS on it, Decap CMS (opens in a new tab), with its GitHub login done by two tiny Pages Functions. The CMS was an experiment. It worked, but it never became a core part of the workflow. I still prefer writing in Markdown in my editor and see the source code.

Today the site runs on Workers, as static assets. If you haven't followed Cloudflare lately: Workers can now serve a folder of files the same way Pages does, and Cloudflare itself recommends Workers for new projects. Pages still works, but new things land in Workers first.

What made me move was the comments. Every post here has a comment section, and that needs a bit of a backend: an API, a database and an email when someone writes something. On Workers it's one project. The build/jaspr folder is the static part, a TypeScript Worker is the dynamic part, and wrangler.jsonc says which paths go where:

"assets": {
  "directory": "build/jaspr",
  "not_found_handling": "404-page",
  "html_handling": "drop-trailing-slash",
  "run_worker_first": ["/api/*", "/admin", "/admin/*"]
}

Everything else is a static file. Requests for static files don't run the Worker script, and they're free and unlimited, so they don't count against the free 100,000 Worker requests a day. The comments live in D1.33 Cloudflare's SQLite database for Workers. The free plan is far more than a comment section needs, and Time Travel lets me restore the database to any minute of the last 7 days, in case I ever break it with a migration.

3 Cloudflare's SQLite database for Workers.

Two things worth knowing before your first deploy:

  • Keep the custom domain out of the config. Set it in the dashboard, leave routes out of wrangler.jsonc and set "workers_dev": false. Otherwise a deploy can reconcile the triggers and detach your domain.
  • "keep_vars": true. Without it, every deploy removes the variables you set in the dashboard. Secrets survive either way.

The build: on Cloudflare, or in CI

Here's where Hugo and Jaspr differ the most.

Cloudflare's build image knows Hugo, Jekyll, Node and a long list of other things. With Hugo you push and Cloudflare does the rest. If your generator is in that list, use it. It's free, it's simple and you get the preview URLs too.

Cloudflare doesn't know Dart, though. You could download the SDK in the build command, but my build also runs the tests, draws the Mermaid diagrams in headless Chrome and applies the database migrations before the deploy. That's a real pipeline, so it lives in GitHub Actions:

  1. type check and test the comments Worker;
  2. dart analyze, dart test and a content check (unknown tags, a cover without alt text and so on);
  3. draw the diagrams, with a cache, so only new ones are drawn;
  4. jaspr build, and check the output: the RSS feed is there, the 404 page is noindex, and no page loads fonts or scripts from another server;
  5. on main only: wrangler d1 migrations apply, then wrangler deploy.

The workflow needs two secrets: an API token, which can only edit Workers and D1, and the account ID. The comments' own secrets, like the Turnstile key, live in the Worker settings, not in the repository. A push to a branch runs the checks without deploying. A push to main publishes the post.

The output check in step 4 is there for privacy, too. The fonts are on the site itself and the diagrams are drawn at build time, so reading a post doesn't ask Google or a CDN for anything, and the site needs no cookie banner.

The only annoying part is that build_web_compilers supports one Dart minor at a time, so CI pins the exact SDK version instead of stable. When I bump Flutter, I bump the version in the workflow too.

Email

My address is on my own domain, and there's no mail server anywhere.

Receiving is Cloudflare Email Routing. You turn it on for the domain, it adds the MX records, and you write rules for which address forwards where. Mine all go to Gmail. It's free and it just works, but it's forwarding only: no inbox, no storage, no webmail.

Sending is Gmail too. In Gmail settings, under Accounts, Send mail as, you add your address as an alias and send it through smtp.gmail.com with an app password (that needs two-factor login on the Google account). Then Gmail lets you pick the address in the From field.

In DNS, the SPF record lists both Cloudflare and Google:

v=spf1 include:_spf.mx.cloudflare.net include:_spf.google.com ~all

DMARC is at p=none for now: nothing gets rejected, I only get reports. Cloudflare collects them and shows them in the dashboard, which is the only way I'll ever read a DMARC report. Before I make the policy stricter, I want to see in those reports that the mail sent through Gmail passes.

This is the weakest part of the setup. For a personal address it's fine. For a business, I'd pay for proper email.

The comments use email too. A Worker can send email through Email Routing to verified addresses, for free, so every new comment sends me a message with the text and a link to the admin page.

The free stuff around it

With the DNS on Cloudflare you get a pile of things you'd otherwise set up or pay for:

  • HTTPS with certificates you never touch.
  • A CDN. The pages come from a data center near the reader.
  • DDoS protection. Nobody will attack this blog, but it's nice to know.
  • Turnstile, a CAPTCHA that mostly doesn't ask you anything. Guest comments go through it.
  • Access (Zero Trust), free for up to 50 users. /admin sits behind it, and it's the only place where comments are moderated, so the login is Cloudflare's problem, not mine. The Worker checks the Access token as well, so the admin stays closed if Access is ever switched off.
  • Rate limiting for the comments API, as a binding in wrangler.jsonc.

All of it is in one dashboard, with one bill. The trade-off is obvious: a lot of my stuff depends on one company. I'm fine with that for a blog. The posts are Markdown in Git and the comments are a SQLite database I can export, so moving somewhere else is a weekend, not a migration project.

Why Jaspr and not Jekyll or Hugo

If you just want a blog, take Hugo. It's one binary, it's fast, it has themes for everything, and Cloudflare builds it for you. That's a hard deal to beat.

I picked Jaspr (opens in a new tab) because it's Dart. I build Flutter apps for a living, and I wanted to see how far I can get on the web without leaving the language I use every day. Jaspr is a web framework, and with jaspr_content it's also a static site generator: Markdown in, HTML out. What I like about it:

  • Components instead of templates. A layout is a Dart class, and so is a <facts /> tag in Markdown. The analyzer tells me when I break something, before the build does.
  • My own Markdown. Side notes, the facts box and photos with captions are Markdown extensions I wrote in Dart, not template tricks.
  • Typed CSS. Styles live next to their component, and every color and size comes from one tokens file. A hard-coded color is a bug, and I can actually enforce that.
  • Tests. Unit tests for the content rules, and golden screenshots of a test site, so I see when a change moves something it shouldn't.
  • No Dart in the browser. Jaspr can run components on the client, but this site doesn't. The reader gets plain HTML, CSS and a few small scripts.

The downsides are real, too. Jaspr is young, and so is jaspr_content, so I sometimes write things myself that Hugo has had for years. The RSS feed is one of them. The build is slower, and Dart in CI needs the version pinning I mentioned. And there's no ready-made theme: you build everything. For me that's the fun part. For most people it isn't.

Would I do it again

Yes. Domain, DNS, hosting, a small backend and email, all in one account, for the price of the domain. A personal site doesn't get anywhere near the free limits.

If you want to copy it, start simple: buy the domain at Cloudflare, put a static site on Workers, turn on Email Routing. Add a Worker when you actually need one. And if your generator is one Cloudflare can build, let it build.

If you have a different setup, or you think mine is wrong somewhere, the comments are below. They run on a Worker, of course.