Ramblings of an aging IT geek
← Ramblings of an aging IT geek
colophon

A Colophon Blog on Cloudflare Pages and R2

A practical walkthrough for standing up a new colophon blog on Cloudflare Pages and R2: API tokens and permissions, DNS, secrets from the keyring, generative images and audio, and the frontmatter that matters.

Listen to this post
A desk of neglected, fading handwritten notes, with a few rising and flowing as a bright stream into a finished blog post on a laptop screen

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 TokenCreate 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.dev and let it verify.
  • Assets: in the R2 bucket, SettingsPublic accessCustom domains → add assets.example.com. That public hostname is what you'll hand colophon as the R2 public_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.

  • production ships only finished posts (draft: false), to blog.example.com, on the main Pages branch, and it's behind the allow_publish: false safety latch so a deploy is never accidental.
  • preview sets include_drafts: true and overrides the Pages branch to preview, 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.defaults in 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 with secret-tool at publish time so the tokens never sit in a plaintext .env or in my shell history. A small wrapper resolves them to the bare names colophon wants, runs colophon doctor as 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_ID and R2_SECRET_ACCESS_KEY straight from secrets.*. 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:: true keeps it out of production but visible in the preview environment.
  • description:: the one-line summary used in listings and as an SEO fallback.
  • hero: / hero_alt:: the banner image (a path, an ![[embed]], or a gen: prompt) and its alt text. Always write real alt text for a meaningful image; an empty hero_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.