Skip to content
hackf5Public

About

A minimal single-author microblog: one Cloudflare Worker, one D1 database and five MCP publishing tools.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

noax.is

Noax.is is a minimal, extensible microblog that you can host for free (excluding domain costs).

Since you do almost everything else through your coding agent, why are you messing around with an admin dashboard and typing into a prosemirror wrapper? Exactly, you're not. The browser and the mouse are so yesterday.

You tell the coding agent to make a post, it makes that post. You tell the agent you want a new feature (a search box, an image gallery) it makes it. No frameworks, no plugins. One Cloudflare Worker, D1 for persistence (maybe you'll need R2 for general storage) all on the free plan and that's it. The world is yours.

See it running at noax.is.

The text below is for your agent, not you. TL;DR; fellow thetan. Leave the "details" to the big guy/gal. Your squishy organic brain is too valuable to be worrying about bash scripts and CSS grids (snooze). If you can dream it you (the agent) can make it.

Stop reading now and point the agent at this page. It will take it from here.

How it works

  • The public site is server-rendered HTML with a strict Content Security Policy and an Atom feed at /feed.xml.
  • Agents can read every page as Markdown, and find their way around with /llms.txt, /robots.txt and /sitemap.xml.
  • Publishing happens through a private MCP endpoint on its own hostname, protected by Cloudflare Access.
  • Posts are Markdown stored in D1, not in Git, so publishing needs no deploy. Creating a post publishes it immediately.

Your blog's settings and credentials stay in an ignored .local/ directory, so you can git pull updates without conflicts.

What you need

  • A domain on an active Cloudflare zone. The blog can be at the apex or on a subdomain. The hostnames you choose mustn't have existing CNAME records.
  • A Zero Trust organisation. Create one once at one.dash.cloudflare.com. Cloudflare asks for payment details even on the Free plan, but doesn't charge for it.
  • Node.js 24 or newer.
  • A Cloudflare API token with the permissions below.
  • An MCP client that can send custom headers. Claude Code and Codex are configured for you.

API token

Create a token in the Cloudflare dashboard with:

Scope Permission Used for
Account Workers Scripts: Edit Deploying, checking domain bindings
Account D1: Edit Creating the database, running migrations
Account Access: Apps and Policies: Edit Protecting the MCP hostname
Account Access: Service Tokens: Edit Creating your publishing credential
Account Access: Organizations, Identity Providers, and Groups: Read Finding your Access team
Zone Zone: Read Finding your zone
Zone Workers Routes: Edit Attaching your hostnames

The Worker never uses this token, and neither does publishing. You can delete it once setup finishes. You'll need a new one to deploy updates, run remote migrations or rotate credentials.

Set up

git clone https://github.com/hackf5/noax.is.git
cd noax.is
npm ci
mkdir -p .local
cp site.example.json .local/site.json    # then edit it
cp .env.example .local/cloudflare.env    # then add your token and account ID
npm run setup

npm run setup creates the Access application, service token and D1 database, applies migrations, deploys the Worker and checks the connection. It's safe to run again, and it stops rather than take over a hostname that belongs to another Worker. Run npm run cloudflare:inspect first if you'd like to see what's on the account; it changes nothing.

If the final check says fetch failed on a first deploy, everything else is already done and the new hostname just isn't reachable from your machine yet. Its certificate usually takes a couple of minutes, but the check can run before the DNS record exists, and your machine may then remember that for up to 30 minutes. Wait, then run npm run mcp:check.

Coding agents can do most of this for you. AGENTS.md explains which steps need you and which they can run.

Publish

Start your client from the repository root:

claude
# or
codex

Use a fresh session after setup. Your client may ask you to approve the project's noax MCP server the first time.

Then say something like "We're about to draft a new article about ...", develop the text in the conversation, and say "post it" when it's ready. The agent publishes the agreed Markdown, checks the public page and gives you the URL.

To give your agent a house style, copy EDITORIAL.example.md to EDITORIAL.md and fill it in. It's ignored by Git and read in editorial sessions.

Tool Arguments Effect
list_posts Optional cursor, limit (1–100; default 20) Read newest first; follow next_cursor for older posts.
get_post id Read Markdown, metadata, current version and public URL.
create_post markdown Publish immediately.
update_post id, markdown, expected_version Replace the live Markdown.
delete_post id, expected_version Delete permanently.

Posts must contain text and fit within 64,000 characters. Markdown supports paragraphs, headings, emphasis, lists, quotes, links and code. Raw HTML and images are disabled. A post's page title comes from its first top-level H1 (# Title), and its description comes from the body text.

The title also supplies the initial public URL, /posts/{slug}, and the homepage heading links to it. Slugs use lowercase ASCII words separated by hyphens and normalise accents. Titles can repeat but slugs can't, so a duplicate gets the next number after the highest in use. A post without an H1 uses the title Post and base slug post. Editing a heading changes the page title while preserving the published URL. MCP results include slug and the canonical url; id remains the UUID used for reading, editing and deleting through MCP. Older UUID URLs redirect permanently to the slug URL.

Read it as an agent

Every page has a Markdown version. A post is its source exactly as written, beneath front matter giving its title, description, URL and dates. That means it shows anything in a post that the HTML page doesn't.

URL What it gives
/, /posts/<slug> with Accept: text/markdown The page as Markdown. Accept: text/plain gets the same text as text/plain.
/index.md, /posts/<slug>.md The same, for clients that can't set headers.
/llms.txt A guide for agents, with the newest posts.
/robots.txt Crawl rules, your content_signal and the sitemap's location.
/sitemap.xml An index of sitemaps, each listing up to 1,000 posts.

Browsers, and any client that doesn't ask for Markdown or prefer plain text, get HTML.

Cloudflare can add its own text above your robots.txt, depending on the zone's AI crawler settings. npm run smoke checks that the Sitemap: line survives.

Configure

Settings live in .local/site.json. Run npm run deploy after changing them.

Key Default Purpose
public_host Required Where the blog is served.
mcp_host mcp. + public_host The private publishing hostname. Must be in the same zone.
title public_host Shown in the header, page titles and feed.
description Notes on + title The default page description and feed subtitle.
locale en Page language and date format. Times are shown in UTC.
content_signal search=yes, ai-input=yes How AI systems may use your posts, published in robots.txt. Add ai-train=yes or ai-train=no to state a preference on training. Compliance is voluntary.

If you change mcp_host, also update NOAX_MCP_URL in .local/mcp.env and run npm run cloudflare:provision.

For anything else, change the code. It's about 850 lines, and the styling is one stylesheet in src/html.ts. Fork the repository if you want to keep those changes.

Costs

noax is designed to fit Cloudflare's free plans. The domain is the only thing you have to pay for.

Limit on the Free plan Allowance
Worker requests 100,000 a day
Worker CPU 10 ms per request
D1 rows read 5 million a day
D1 rows written 100,000 a day
D1 database size 500 MB

A page view normally costs one Worker request, as browsers cache the stylesheet and favicons. The stylesheet's URL changes with its content, so browsers can keep it for good and still pick up changes straight away. Posts are rendered when they're written, so reading the site does no Markdown work.

The feed, llms.txt, sitemaps, robots.txt, the stylesheet and favicons go through Workers Cache, which sits in front of the Worker. A cache hit uses no Worker CPU and no D1 reads, though Cloudflare still bills it as a request, so assume it counts towards the daily limit. Each deploy starts with an empty cache. The feed, llms.txt and sitemaps can lag by up to five minutes, so a deleted post can linger in them briefly. Pages and Markdown aren't cached, so edits and deletions show on the next request.

Rendered output is a cache, so posts pick up rendering changes automatically after an update. Each post is rebuilt the first time it's read and saved straight away. On the Free plan, a page of long posts can exceed the CPU limit while this happens and show a Cloudflare error. No work is lost, though, so the page loads after a few refreshes.

If you go over a daily limit on the Free plan, you get errors rather than a bill, and the limits reset at 00:00 UTC. The Worker has no rate limiting of its own, so anyone sending enough requests can use up the day's allowance.

Sitemaps cost more than pages. A full sitemap file reads 1,000 rows and /sitemap.xml reads every post, where a page reads about 20. The cache means repeat requests read D1 about once every five minutes rather than every time, but it isn't a rate limit. The query string and some headers are part of its key, so someone deliberately varying them still reaches D1. On a blog with thousands of posts, about 5,000 such requests would use up the day's D1 reads, and every page would fail until the reset. Sitemaps are split so that each request stays within the CPU limit however many posts there are.

These limits come from Cloudflare's Workers and D1 documentation. Check there for current figures.

Operate

Update

git pull
npm ci
npm run db:migrate:remote
npm run db:backfill-slugs:remote
npm run deploy
npm run db:backfill-slugs:remote

Run the migration first. This needs an API token in .local/cloudflare.env.

The slug migration allows null values so the previous Worker can keep publishing during deployment. The idempotent backfill assigns slugs in publication order without changing Markdown, timestamps or versions. Run it before deployment and again afterwards to cover posts created by the previous Worker during rollout; it checks that no posts without slugs remain. Until backfilled, legacy posts keep their UUID URLs. The remote backfill uses the provisioning credentials, separately from the MCP Access service token.

Back up

Posts exist only in D1, and deleting one is permanent. D1 Time Travel keeps 7 days of history on the Free plan and 30 on paid plans. To export a copy:

node --env-file=.local/cloudflare.env node_modules/wrangler/bin/wrangler.js d1 export DB \
  --remote --output .local/backup.sql --config .local/wrangler.deploy.jsonc

Look after .local/

File Contains If you lose it
site.json Your blog's settings Recreate it from site.example.json.
cloudflare.env Your API token Create a new token.
mcp.env Your publishing credential Run npm run cloudflare:rotate.
provisioning.json Cloudflare resource IDs Run npm run cloudflare:provision.
wrangler.deploy.jsonc Generated deploy config It's regenerated on every deploy.

.mcp.json and .codex/config.toml are generated in the same way. Back up .local/ and EDITORIAL.md with your other secrets, as a new checkout needs them.

Don't run scripts/mcp-headers.mjs directly in a logged shell, because its output is your credential. Use npm run mcp:check.

Renew the publishing credential

The Access service token lasts a year, and publishing stops when it expires. npm run mcp:check warns you 30 days ahead. npm run cloudflare:rotate issues a new secret and extends the expiry, and only needs a token with Access: Service Tokens: Edit.

Verify a deployment

npm run smoke checks publishing and agent access end to end. It briefly publishes a clearly labelled post, tests editing and conflict handling, and deletes it, so only run it if a short-lived public post is acceptable.

Develop

npm ci
npm run db:migrate:local
npm run db:backfill-slugs:local
npm run dev

Open http://localhost:8787. All of this works on a fresh clone with no Cloudflare account, using a local database. MCP authentication stays on locally, so to see a post, add one directly:

npx wrangler d1 execute DB --local --command "INSERT INTO posts (id, markdown, created_at, updated_at) VALUES ('00000000-0000-4000-8000-000000000001', '# Hello', strftime('%Y-%m-%dT%H:%M:%fZ'), strftime('%Y-%m-%dT%H:%M:%fZ'))"

Before sending a change:

npm run typecheck
npm test
npm run build

The tests cover the full MCP protocol with generated signing keys, and provisioning against a fake Cloudflare API.

Scope

Minimalism is the point. noax has no dashboard, drafts, comments, images, tags, search or multiple authors, and it isn't likely to gain them. Bug fixes and changes that make it smaller or easier to run are very welcome. Please open an issue before starting anything larger.

To report a vulnerability, see SECURITY.md.

Licence

MIT

About

A minimal single-author microblog: one Cloudflare Worker, one D1 database and five MCP publishing tools.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages