I rebuilt this blog on colophon a few days ago and said it deploys with one command. This is the post that earns that claim: standing up a brand new colophon site on Cloudflare Pages, with the images and search index living in R2 rather than shipping with the deployment. Nothing here is secret sauce, but the order of operations matters, and a couple of the steps are easy to get subtly wrong.
The shape of it: Pages serves the HTML, R2 serves the heavy assets. Pages has a file
count and size budget; a blog with a few hundred generated hero images will blow through it.
So colophon routes anything matching **/assets/** and the search index to an R2 bucket and
rewrites the URLs to point there. The reader's browser fetches pages from one origin and
images from another, and neither you nor they have to think about it.
What you need from Cloudflare
Make a Cloudflare account if you haven't, then collect three things.
Account ID
It's in the dashboard URL once you're logged in
(dash.cloudflare.com/<account-id>/...), and on the right-hand sidebar of any zone's overview
page. This is not a secret, but colophon needs it for both the Pages and R2 publishers.
API Token
Profile → API Tokens → Create Token → Create Custom Token. The publishing docs list the exact permissions, and they are tighter than the "Edit Cloudflare Workers" template you'll be tempted to grab. For one token that does both jobs:
- Account → Cloudflare Pages → Edit: deploys the site.
- Account → Workers R2 Storage → Edit: reads and writes objects, and creates the bucket.
That second one is the gotcha. The S3-style colophon publish --create step also flips the
bucket to public and sets a CORS policy, and that needs R2 Admin read and write, not just
object read and write. If --create later complains it "needs R2 Admin Read & Write", this is
why. You can use a narrower object-only token for day-to-day publishing once the bucket exists.
An R2 access key pair
R2 → Manage R2 API Tokens → create one. You get an
R2_ACCESS_KEY_ID and an R2_SECRET_ACCESS_KEY; R2 speaks S3, so these are the S3 credentials
the publisher uses, separate from the Cloudflare API token above.
DNS and a custom domain
Two domains, ideally: one for the site, one for the assets. I serve the blog at blog.i0.pm
and assets at assets.i0.pm.
- Site: in the Pages project, Custom domains → add
blog.example.com. If the zone is on Cloudflare, the CNAME is created for you; if not, add a CNAME to<project>.pages.devand let it verify. - Assets: in the R2 bucket, Settings → Public access → Custom domains → add
assets.example.com. That public hostname is what you'll hand colophon as the R2public_url, and until it resolves, the asset routing stays dormant and builds keep assets co-located. Handy for local work.
Scaffold the project
init writes the project; publish --create provisions the destinations later. Don't conflate
the two.
colophon init blog && cd blog
That gives you a colophon.yaml, a content/ directory (which is also an Obsidian vault, if
you want it), an author, and a persona. Open colophon.yaml and wire up the two publishers and
a production environment. The key point, and the thing the
publishing docs
lean on hard: no secret ever goes in this file. Non-secret settings use {env:VAR}
interpolation; credentials are read straight from the environment and never touch the YAML.
sites:
- id: main
title: "Your blog"
base_url: "{env:SITE_URL:-http://localhost:8080}"
routing:
- match: "**/assets/**" # heavy assets → R2, not the Pages bundle
publisher: r2
- match: "_search/**" # search index fetched cross-origin → R2 (needs CORS)
publisher: r2
publishers:
- id: cf
driver: cloudflare-pages
project: "{env:CF_PAGES_PROJECT:-my-blog}"
account_id: "{env:CLOUDFLARE_ACCOUNT_ID}"
- id: r2
driver: cloudflare-r2
bucket: "{env:R2_BUCKET:-my-blog-assets}"
account_id: "{env:CLOUDFLARE_ACCOUNT_ID}"
public_url: "{env:R2_PUBLIC_URL:-}" # https://assets.example.com; empty keeps routing inert
environments:
- name: production
publish: [cf, r2]
allow_publish: false # safety latch: deploy needs --allow-publish
base_url: "https://blog.example.com"
- name: preview
publish: [cf, r2]
include_drafts: true # drafts visible here, never in production
overrides:
cf:
branch: preview # its own Pages branch / preview URL
colophon env lists every {env:VAR} the project references, set or not, which is the quickest
way to see what you still owe it.
Sites, publishers, environments: why three nouns
colophon deliberately splits the job three ways, and it's worth understanding because it's what makes the rest painless. A site is the content and its identity (title, theme, what gets written). A publisher is pure mechanism: how to ship bytes somewhere (to Pages, to R2, to a local folder), with no opinion about when or why. An environment is the policy that ties them together: a named build-and-deploy profile that says which publishers to use, whether to include drafts, and any overrides. The publishing docs put it as publishers being how and environments being what and where.
That separation is why I run two environments off the one site: production and preview.
They share the same content, the same publishers, the same R2 bucket; they differ only in policy.
productionships only finished posts (draft: false), toblog.example.com, on themainPages branch, and it's behind theallow_publish: falsesafety latch so a deploy is never accidental.previewsetsinclude_drafts: trueand overrides the Pages branch topreview, so it builds the work-in-progress and Cloudflare serves it at its own preview URL. I can read a draft on my phone, exactly as it'll render, before anyone else sees it.
I publish both because they answer different questions. Preview is "does this actually look right,
live, with the real images and search index?" Production is "the world can read this now."
Splitting them means a draft can be deployed somewhere real and private without ever risking the
public site, and promoting it is just flipping draft: false and publishing production. Same
content, two policies, one command each:
colophon publish --env preview --allow-publish # drafts, preview branch
colophon publish --env production --allow-publish # finished posts only
Nothing about either environment is special or built in; you could add a staging, a
second-site mirror, or a local dist target for diffing output. Two is just the smallest set
that gives me "private dress rehearsal" and "live" without duplicating a scrap of content.
Where the secrets live
The interpolated {env:VAR} values split cleanly into two piles:
- Not secret (
CLOUDFLARE_ACCOUNT_ID,CF_PAGES_PROJECT,R2_BUCKET,R2_PUBLIC_URL,SITE_URL): commit these to a.env.defaultsin the repo. colophon loads it automatically. - Secret (
CLOUDFLARE_API_TOKEN,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,MINIMAX_API_KEY): never in the repo, never in config. These come from the environment at publish time.
How they reach the environment depends on where you are:
-
Locally: out of the Secret Service. I keep mine in rosec (the keyring replacement I wrote about in Secrets, Minus the Keyring), namespaced
COLOPHON_*, and pull them withsecret-toolat publish time so the tokens never sit in a plaintext.envor in my shell history. A small wrapper resolves them to the bare names colophon wants, runscolophon doctoras a preflight, then publishes:# store once: secret-tool store --label='colophon: …' service colophon key COLOPHON_<NAME> for name in CLOUDFLARE_API_TOKEN R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY MINIMAX_API_KEY; do export "$name=$(secret-tool lookup service colophon key "COLOPHON_$name")" done colophon doctor && colophon publish --env production --allow-publish -
In CI: as encrypted GitHub Actions secrets, exposed to the job as env vars. colophon scaffolds a workflow that reads
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEYstraight fromsecrets.*. Same variable names, different source.
The whole design is that the names are constant and only the source changes, so the same
colophon publish runs unmodified on your laptop and in Actions.
Provision, then publish
First time only, let colophon create the Pages project and the R2 bucket (idempotent, so it's safe to leave in):
colophon publish --env production --create --allow-publish
--create makes the Pages project, creates the bucket, and sets the bucket's CORS policy so the
cross-origin fetch() of the search index works. A cross-origin <img> needs no CORS; a
fetch() or an ES import() does, which is why the search index specifically needs it. After
that, the daily incantation is just:
colophon publish --env production --allow-publish
Generative images and a spoken reading
This is the part I enjoy. colophon can generate hero images from a text prompt and a spoken
reading of each post. The provider is your choice: image generation can go through Google
GenAI or OpenRouter just as happily, and you're not obliged to use
any of it. I picked MiniMax (the same provider
tinct uses for palettes) because it's cheap and effective, and one
key, MINIMAX_API_KEY, drives both the images and the speech. Swap the provider: lines below
if you'd rather use something else.
generation:
image:
provider: minimax # image-01; api_key falls back to MINIMAX_API_KEY
system_prompt: "editorial tech illustration, dark muted palette, full-bleed, no text"
defaults: { aspect: "16:9" }
speech:
provider: minimax # speech-2.6-hd; same key
enabled: true # every post gets a reading unless it sets audio: false
The clever bit, documented in
image-generation.md,
is that the repo is the cache. Any hero: or inline image whose value starts with gen: is
generated from the prompt, content-addressed, and the result is committed to the repo. A normal
colophon build ships whatever's cached and never calls the API; you only spend tokens when you
explicitly ask:
colophon build --generate-ai # or: colophon publish ... --generate-ai
So a hero is just a sentence in the frontmatter:
hero: "gen:a worn mechanical keyboard lit by a single monitor in a dark room"
hero_alt: A worn mechanical keyboard lit by a single monitor in a dark room
Change the system_prompt and you change the cache identity, so the next --generate-ai
regenerates everything. Useful to know before you tweak it at midnight and wonder why your whole
back catalogue is redrawing.
The reading uses one of the provider's stock voices by default, but you don't have to settle for
a stranger reading your words. MiniMax (and the others) let you clone a voice from a short
sample of your own, which gives you a voice profile ID you can point colophon at. Set it once on
the author or persona as voice:, or per post with audio_voice:, and every reading goes out in
your own voice rather than a generic one:
audio_voice: "my-cloned-voice-id" # frontmatter: this post in your own voice
That's on my list: the default here is a placeholder until my own clone is trained.
The frontmatter that actually matters
You don't write most of this by hand, and that's the point. colophon new post "Title" picks a
unique pinned slug:, stamps the date:, sets the byline author: and the writing persona:,
and leaves the post draft: true. The fields worth understanding, all detailed in
content.md and
seo.md:
slug:: pinned at creation so the URL never drifts when you retitle. Leave it alone.draft::truekeeps it out of production but visible in thepreviewenvironment.description:: the one-line summary used in listings and as an SEO fallback.hero:/hero_alt:: the banner image (a path, an![[embed]], or agen:prompt) and its alt text. Always write real alt text for a meaningful image; an emptyhero_alt:marks a purely decorative one.seo:: an optional block for a distinct page title, keywords, and social-card copy when the defaults aren't enough.audio: false: opt a single post out of the spoken reading.
Writing it with an LLM, in your own voice
If you're writing with an LLM or an agent harness, this is where it gets genuinely pleasant, and it's the part I'm most quietly pleased with. colophon ships a set of agent skills, the most important being write. There are companions for metadata, cross-linking and publishing too, but write is the one that matters, because it solves the thing that makes LLM-written blogs so obviously LLM-written: they all sound the same.
The trick is that the model never starts from a blank prompt. Before it writes a word, the skill runs:
colophon persona context default --topic "what the post is about"
That does two things. It hands over the persona's style guide (the explicit rules: British spelling, no em dashes, lead with the point, the tone), and then it reaches into the corpus of your past posts and pulls back the handful most relevant to this topic, ranked with BM25. So a post about distributed systems gets fed your actual previous writing on distributed systems; a hardware teardown gets your teardowns. The model isn't told "write like a thoughtful British engineer" in the abstract, it's shown concrete examples of how you actually write, about this kind of thing, and asked to continue in that vein. The voice it produces is a reflection of your own corpus, not a generic pastiche of one, and it sharpens as the corpus grows: every post you publish becomes an exemplar the next one can learn from.
With the voice loaded, colophon new post scaffolds the file with the frontmatter contract
already correct, and the model fills the body and the prose-level fields (description, alt text,
tags), told not to touch the pinned slug or invent links. The harness handles the bookkeeping;
you (or the model in your voice) handle the words. This very post was drafted that way.
colophon new post "Raft Leader Election" --author you --persona default --tag distributed-systems
colophon serve --open=latest # preview before you flip draft: false
Flip draft: false, run the publish command, and it's live. One command, as promised. The
infrastructure is your own fault now, which is rather the appeal.
