An el-cheapo blog on Cloudflare: static Next.js, TinaCMS in local mode, no monthly bill

Isometric illustration of a laptop on a desk with a stack of paper pages beside it, one page floating up toward a small lilac cloud, a single coin and a mug on the desk.

In short. Next.js with output: "export" produces plain files. TinaCMS runs in local mode, so there is an editor at localhost:3000/admin/index.html and no cloud account. The files are served by Cloudflare Workers static assets on the free tier. Merging never publishes; publishing a GitHub Release does, through a workflow in a protected environment that holds one narrow token. The bill is the domain.

What "cheap" has to include

Cheap is easy if you also accept fragile. The constraints here were:

The stack

flowchart LR
  E["Local editor<br/>TinaCMS at localhost:3000/admin/index.html<br/>or any text editor"] -- "saves Markdown" --> G["Git repository<br/>content/posts/*.md"]
  G -- "pull request, CI green" --> M["main"]
  M -- "publish a GitHub Release<br/>workflow in a protected environment" --> B["next build, output: export<br/>plain HTML, CSS, JS"]
  B -- "wrangler deploy, tagged with the released commit" --> W["Cloudflare Workers<br/>static assets, free tier"]
  W --> R["Reader"]
  V["deploy-status check<br/>in sync, behind, untagged"] -.-> W

Three decisions made on purpose

Publish by release, not by push

The obvious design is "push to main, a pipeline deploys". That makes every merge a deploy and puts a broad token in the pipeline. Instead, merging to main only stages a change. Publishing a GitHub Release triggers a workflow in a protected production environment that only release tags may use, and that environment holds the one Cloudflare secret: a token limited to Workers Scripts Edit and nothing else. The script refuses pre-releases and any commit that is not on main, runs the tests and the static build, deploys with a pinned tool version tagged with the released commit, then re-reads the live version and fetches the live site to confirm. A read-only status check reports in sync, behind or untagged. Rolling back is publishing an earlier release again.

A manual deploy from the laptop still exists as the fallback, with a separate control-plane token resolved from a password manager behind a biometric prompt. Either way, publishing is a deliberate act by a person, and a merge on its own never changes the live site.

One token with a permission list, expiring yearly

The first token was "all resources". A screenshot exposed it and it had to be rotated. The replacement is a control-plane token with an explicit permission list, separate from a data-plane key limited to two storage buckets, both expiring yearly, and the global API key disabled. The decision record says why, so the next person does not create another all-resources token because it was quicker.

A palette with a test

The colours derive from the logo in a perceptual colour space, and a unit test checks every text, link and control pairing against WCAG AA in both light and dark modes. Change a token and the test says whether it still reads. It is a small thing that stops the site slowly getting harder to read as it is tweaked.

What it costs

ItemCost
Domain registrationyearly, the only line
Cloudflare DNS, Workers static assets, email routingfree tier
Outbound mail for the domainfree tier of a relay that aligns SPF and DKIM
TinaCMS local mode, Next.js, Bunnothing
Build minutesa few per release, inside the free allowance

Operating rules that keep it cheap and safe

FAQ

Why not GitHub Pages or a static host with a built-in pipeline? Either would work. Cloudflare already held the DNS and the mail routing for the domain, so one provider covers the lot, and Workers static assets will serve a small function later if one is ever needed.

Why not just use Tina Cloud and edit on the live site? It would add an account, a client ID and a read token to the build, which is three more things to rotate and one more place content could be edited without a pull request. For one author it is not worth it.

Does a static export limit the site? There is no server-side rendering, no form handling, and no per-request logic. For a blog, those are features.

Related