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

In short. Next.js with
output: "export"produces plain files. TinaCMS runs in local mode, so there is an editor atlocalhost:3000/admin/index.htmland 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:
- No monthly bill. Free tiers only, and nothing that silently becomes paid at a traffic level a blog might reach.
- No vendor holds the content. Posts live in a Git repository as Markdown with frontmatter. If every service in this post disappeared, the content would still be files.
- No broad credential anywhere. A token in a build pipeline is a token in a place that gets copied, logged and screenshotted, so the only one that exists is narrow, and lives where only a release can reach it.
- Editing must be pleasant enough that writing actually happens. A text editor is fine for me; a form with a preview is better on a bad day.
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- Next.js, static export.
next buildwrites HTML and assets to a folder. No server runs in production, which is what makes the free tier safe: there is nothing to scale. - TinaCMS in local mode.
bun run devstarts the site and the editor. The editor reads and writes the Markdown files on disk, with the schema in one TypeScript file. There is no Tina Cloud account, no client ID, no token. The hosted editor on the live site is a feature deliberately not used; the schema is ready if that ever changes. - Markdown with Mermaid. Posts are plain Markdown. A fenced
mermaidblock is turned into a diagram in the browser by a small client component; without JavaScript the source stays visible as text, which is also what search engines index. - Cloudflare Workers static assets. The build output is served as static files. The free tier's request limits are far above what a blog sees.
- DNS and the apex redirect live in a sibling repository with the rest of the domain, applied by plan-then-apply scripts with a strict audit that flags any record the files do not account for.
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
| Item | Cost |
|---|---|
| Domain registration | yearly, the only line |
| Cloudflare DNS, Workers static assets, email routing | free tier |
| Outbound mail for the domain | free tier of a relay that aligns SPF and DKIM |
| TinaCMS local mode, Next.js, Bun | nothing |
| Build minutes | a few per release, inside the free allowance |
Operating rules that keep it cheap and safe
- The repository has no secrets, and a secret scanner runs on every commit and over history.
- A post's slug never changes after publishing without a redirect.
- Images go in the repository with descriptive names and alt text.
- Every change is a pull request with CI green, and every session of automated help is logged to an issue.
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.