colophon separates what/where (environments) from how (publishers).
- A publisher is a deploy mechanism: copy to a folder, upload to Cloudflare Pages, push to an object store. Publishers are pure mechanism and carry no policy.
- An environment is a named build+deploy profile: which publishers to deploy to, whether to include drafts, and optional overrides (title, base_url, theme).
publishers:
- id: local
driver: local
path: ./dist
environments:
- name: production
publish: [local]
allow_publish: false # safety latch: requires --allow-publish to deploy
colophon publish --env production --allow-publish
Publishers
| Driver | Purpose |
|---|---|
local |
Copy the built tree to a directory (offline preview / diffing). |
cloudflare-pages |
Deploy the site to Cloudflare Pages (direct upload). |
cloudflare-r2 |
Upload files to Cloudflare R2 (S3 + R2 control-plane: public-URL discovery, --create expose). |
s3 |
Upload files to any S3-compatible store (MinIO, B2, Wasabi, Amazon S3) — pure data plane, no SDK. |
tigris |
The s3 driver with Tigris (Fly.io) defaults — needs only a bucket. |
git |
Force-push the built tree to a branch of any git remote (GitHub/GitLab/Codeberg Pages, mirrors, self-hosted). |
github-pages |
The git driver with GitHub-friendly defaults (branch gh-pages). |
command |
Run any CLI against the built tree (surge, Netlify, Vercel, rsync, …) — the escape hatch. |
Configuration and interpolation
colophon has two distinct interpolation layers — they look similar ({…}) but resolve at
different times, and every driver supports the first:
1. Config interpolation — {env:VAR} (all drivers, all config). Any string value in the
config may reference the environment, resolved before the YAML is parsed, so it works in any
setting of any publisher (or anywhere else in the config):
| Form | Resolves to |
|---|---|
{env:VAR} |
the value of VAR, or empty if unset |
{env:VAR:-default} |
the value of VAR, or default if unset |
Values come from the process environment and from .env / .env.defaults (loaded first; a real
env var wins over a .env entry). colophon env lists every {env:VAR} a project references,
set or not. This is how non-secret settings stay flexible while secrets stay in the
environment — you never write a token into config:
publishers:
- id: r2
driver: cloudflare-r2
bucket: "{env:R2_BUCKET:-my-assets}" # default when unset
account_id: "{env:CLOUDFLARE_ACCOUNT_ID}" # required; empty if unset
public_url: "{env:R2_PUBLIC_URL:-}" # optional; empty default
2. Command interpolation — {dir}, {public_url}, … (the command driver only). The
command publisher additionally interpolates its command argv at publish time with runtime
values (the materialised directory, the manifest path, …) and the publisher's own settings — see
Run any CLI. The two layers compose: {env:VAR} is
substituted when the config loads, then {placeholder} when the command runs, so a single
command entry can use both.
Per-driver settings and their interpolation are documented in each driver's README — linked from the Publishers table targets below and listed in Secrets and permissions.
Secrets and permissions
Deploy credentials are never read from config — they come from the environment, so they never pass through the agent or the YAML:
| Publisher | Secret env vars | Token permission |
|---|---|---|
cloudflare-pages |
CLOUDFLARE_API_TOKEN |
Account → Cloudflare Pages → Edit |
cloudflare-r2 |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY (or AWS_*) |
R2 → Object Read & Write (+ bucket-create for --create) |
s3 |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
Object read & write (+ bucket-create for --create) |
tigris |
TIGRIS_ACCESS_KEY_ID / TIGRIS_SECRET_ACCESS_KEY (or AWS_*) |
Tigris access key (tid_/tsec_) — Editor |
git / github-pages |
GITHUB_TOKEN / GH_TOKEN / GIT_TOKEN (HTTPS remotes only) |
Repo contents → write (e.g. a GitHub fine-grained PAT or GITHUB_TOKEN in Actions). SSH remotes use the agent — no token. |
Non-secret settings (account id, bucket, project) may use {env:VAR}
config interpolation. Each driver's README documents its
settings and interpolation:
local,
cloudflare-pages,
cloudflare-r2,
s3 / tigris,
git / github-pages,
command.
Provisioning with --create
colophon publish --env <name> --create provisions destinations before deploying:
cloudflare-pages creates the Pages project; cloudflare-r2 / s3 / tigris create the
bucket. All are idempotent — an existing destination is left untouched.
For the object stores, --create also sets a CORS policy allowing cross-origin GET/HEAD
from any origin (via the S3 PutBucketCors API — the only way to configure CORS on R2, which has
no dashboard for it). This matters when assets are fetched with fetch() or imported as an ES
module rather than via an <img>/<script> tag — notably a routed search index (below): a
cross-origin <img> needs no CORS, but fetch()/import() do. The step is best-effort: if a
store doesn't support PutBucketCors, the publish warns and continues, and you set CORS manually.
Generic S3 / MinIO / Backblaze / Wasabi
The s3 driver is plain S3 (SigV4) with no control-plane code — point it at any S3-compatible
store with an endpoint and region:
publishers:
- id: s3
driver: s3
bucket: my-assets
endpoint: "https://s3.us-east-1.amazonaws.com" # or http://localhost:9000 (MinIO)
region: us-east-1
public_url: "https://my-assets.s3.us-east-1.amazonaws.com"
Credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. publish --create creates
the bucket (idempotent). public_url is how colophon learns the public base URL — there's no
control-plane lookup, so set it (a route with no resolvable URL stays inactive).
Tigris (Fly.io)
Tigris is Fly.io's global object store, and it's plain S3 —
colophon talks to it with the same client as any S3 store, so no flyctl / Fly SDK /
control-plane token is involved. The tigris driver is the s3 driver with the endpoint
(https://t3.storage.dev) and region (auto) defaulted, so it needs only a bucket:
publishers:
- id: assets
driver: tigris
bucket: my-blog-assets
public_url: "https://my-blog-assets.t3.storage.dev" # the bucket's public/CDN domain
Credentials come from TIGRIS_ACCESS_KEY_ID / TIGRIS_SECRET_ACCESS_KEY (the tid_/tsec_
keys, falling back to AWS_*). publish --create creates the bucket. Two things are one-time
dashboard settings (Tigris has no data-plane API for them, which is what keeps publishing
SDK-free): make the bucket public to serve a site from it, and optionally attach a custom
domain (a CNAME to <bucket>.t3.storage.dev). Newer accounts serve public content from
t3.tigrisfiles.io — set public_url to whatever the bucket actually serves at.
Provisioning credentials is separate:
flyctl storage createissues a bucket + keys, but that's a one-time setup step, not part ofcolophon publish. colophon only consumes the keys from the environment.
Git-based hosting (GitHub / GitLab / Codeberg Pages)
The git driver publishes by force-pushing the built tree as a single orphan commit to a
nominated branch of a git remote. Whatever serves that branch — GitHub Pages, GitLab Pages,
Codeberg Pages, a mirror, a self-hosted bare repo — then serves the site. It uses
go-git (pure Go), so no git binary is required.
Because each publish is a fresh orphan commit, the branch always mirrors exactly the current build — there's no history to drift and no stale files to prune. It never touches your working tree: the build is staged in a temp repo and pushed from there.
| Setting | Default | Purpose |
|---|---|---|
repo |
(required) | Remote URL (https://…, git@host:owner/repo, ssh://…) or local path. |
branch |
main (gh-pages for github-pages) |
The branch to force-push. |
public_url |
(provider-derived) | The site's canonical URL. Auto-derived for known hosts (below); set it for a custom domain. |
commit_author / commit_email |
colophon / colophon@users.noreply.github.com |
Author of the publish commit. |
commit_message |
colophon: publish <timestamp> |
Commit message. |
public_url is auto-derived from the remote for known hosts, so you usually don't set it:
| Host | Repo | Derived URL |
|---|---|---|
github.com |
me/blog |
https://me.github.io/blog/ |
github.com |
me/me.github.io |
https://me.github.io/ (user/org site) |
gitlab.com |
me/site |
https://me.gitlab.io/site/ |
codeberg.org |
me/pages |
https://me.codeberg.page/ |
Anything else (a self-hosted host, a custom domain via a CNAME) resolves no URL — set
public_url explicitly.
GitHub Pages
publishers:
- id: pages
driver: github-pages # branch defaults to gh-pages
repo: "git@github.com:me/blog.git" # SSH: pushes via your ssh-agent
environments:
- name: production
publish: [pages]
allow_publish: true
In GitHub Actions, use an HTTPS remote and the workflow token instead of SSH:
- id: pages
driver: github-pages
repo: "https://github.com/me/blog.git" # GITHUB_TOKEN → push over HTTPS
Then point the repo's Settings → Pages at the gh-pages branch.
GitLab Pages
- id: pages
driver: git
repo: "git@gitlab.com:me/site.git"
branch: pages # match your .gitlab-ci.yml `pages` job source
GitLab serves Pages from a CI job, so the branch is whatever your pages: job builds from.
Codeberg Pages
- id: pages
driver: git
repo: "git@codeberg.org:me/pages.git"
branch: pages # Codeberg serves the `pages` branch
Any git remote
git is not GitHub-specific — push to a mirror, a self-hosted Forgejo/Gitea, or a local bare
repo (handy for tests). Set public_url since the host is unknown:
- id: mirror
driver: git
repo: "git@git.example.com:web/site.git"
branch: deploy
public_url: "https://www.example.com"
Authentication follows the remote scheme: an https:// remote uses a token from
GITHUB_TOKEN / GH_TOKEN / GIT_TOKEN; an git@… / ssh:// remote uses your SSH agent; a
local path needs neither.
Run any CLI: the command publisher
When no built-in driver fits, the command driver runs an arbitrary CLI against the built tree
— so any deploy tool that takes a directory (surge, Netlify, Vercel, Wrangler, exe.dev, Azure
SWA, rsync, scp, aws s3 sync, a bespoke script) is a publisher with no driver of its own.
colophon materialises the (routed) tree to a temp directory and runs your command there, with
the directory as its working dir, the parent environment inherited (so the tool's own token var
flows through), COLOPHON_* context injected, and CI=true. A non-zero exit fails the publish.
publishers:
- id: surge
driver: command
command: ["surge", "{dir}", "myblog.surge.sh"] # argv list — never a shell
public_url: "https://myblog.surge.sh" # SURGE_TOKEN comes from the env
- id: rsync
driver: command
host: "deploy@example.com:/var/www/blog" # any custom setting → {host}
command: ["rsync", "-az", "--delete", "{dir}/", "{host}"]
public_url: "https://www.example.com"
The command is an argv list executed directly — never through a shell, so there's no shell
injection surface. For a one-liner with pipes or &&, make the shell explicit:
["sh", "-c", "aws s3 sync {dir} s3://bucket --cache-control max-age=3600"].
Interpolation. Every argument is interpolated with {placeholder} tokens drawn from your own
publisher settings plus colophon runtime values (which win on a clash): {dir} / {output_dir}
(the materialised tree, also the CWD), {manifest} (a JSON file classifying each path as
page/asset/feed/… with content-type and size, written beside the tree so it isn't published
unless you reference it), {public_url}, {id}, {file_count}, and any setting you declare
({host}, {domain}, {project}, …). Unknown placeholders error, so a typo fails loudly.
Per-environment overrides vary any setting, so one command publisher can target staging vs
production. The same values are exposed as COLOPHON_OUTPUT_DIR / COLOPHON_MANIFEST /
COLOPHON_PUBLIC_URL / … env vars.
Secrets stay in the environment. colophon never injects a token into the command line — the child inherits the environment and the target tool reads its own
$SURGE_TOKEN/$VERCEL_TOKENthere, matching colophon's env-only rule and the deploy-CLI best practice of keeping credentials out of argv (where they'd leak into process listings and shell history). The command runs with your privileges from your own config — same trust as a Makefile — and is gated behind--allow-publishlike every deploy.
Per-environment overrides
An environment can override any publisher setting via overrides, keyed by publisher id —
so one publisher definition serves several environments. For example, a single local
publisher can write a distinct output directory per environment (handy for previewing a
different theme side by side) without defining a publisher per env:
publishers:
- id: local
driver: local
path: ./dist # default output dir
environments:
- name: dist
publish: [local] # → ./dist
- name: text
publish: [local]
theme: minimal
overrides:
local:
path: ./dist-text # same publisher, a different dir for this env
Overrides also carry per-environment publisher tweaks like a Cloudflare Pages branch.
Redirects (aliases)
A post's aliases: (Authoring → Redirects) produce, at build
time, a meta-refresh stub per old URL, a root _redirects file, and a root .nojekyll. How that
becomes a redirect depends on the host:
| Host | Result |
|---|---|
| Cloudflare Pages, Netlify, GitLab Pages | real 301 from _redirects |
| S3 static-website hosting | real 301 — the s3 publisher sets the object redirect header on publish |
| Cloudflare R2, bare S3/MinIO, local | client-side meta-refresh stub (no in-bucket redirects) |
| GitHub Pages | client-side meta-refresh stub; the .nojekyll is what keeps _search/ and the stubs from being stripped |
So redirects work everywhere; hosts that support server-side rules get a true 301, the rest fall
back to the (always-emitted) stub. colophon doctor warns about alias conflicts before you ship.
WebSub (real-time feeds)
WebSub lets subscribers get your new posts pushed instantly
instead of polling. List one or more public hubs and colophon does both halves: it advertises them
in every feed (<link rel="hub"> in RSS/Atom, a hubs entry in JSON Feed, plus rel="self"), and
pings them after each successful publish so the hub re-fetches and fans out to subscribers.
sites:
- id: main
federation:
websub:
hubs:
- https://pubsubhubbub.appspot.com/ # Google's public hub (or run your own)
The ping is best-effort: it runs only on a real deploy (a public base_url, not gated), and a hub
that's slow or down only logs a WEBSUB … ping failed line — it never fails the publish. No hubs
configured → nothing is advertised or pinged.
On-site search
colophon builds a static search index at build time — a sharded, content-addressed BM25 index
a tiny browser reader queries client-side (no server, no service). Enable it per site with the
search stanza:
sites:
- id: main
search:
mode: lexical # off (default) | lexical
fuzzy: true # opt-in typo tolerance (trigram + Levenshtein); roughly doubles the index
The string shorthand search: lexical still works (equivalent to mode: lexical, no fuzzy).
mode—lexicalturns search on; omitted/offleaves it out entirely (no index, no box).fuzzy— when on, a query token that finds no exact/prefix match falls back to typo-tolerant matching (so "wikilnk" finds "wikilinks"). It's opt-in because the trigram index it needs roughly doubles the index size — a cost a low-bandwidth search shouldn't pay unasked.
Results are prefix-matched by default ("wiki" → "wikilinks"), with query-aware highlighting
and an occurrence count. The index + reader ship only when search is on, under _search/; a theme
renders the box (the press, press-gazette and press-broadsheet themes include one). colophon search "<query>" queries
the same engine from the CLI (always fuzzy), in text or --json.
For large sites, the index can be routed to an object store to keep it off a Pages-style file
budget — see Routing the search index below (it also covers the CORS
that a cross-origin index needs, set automatically by --create).
Routing assets to an object store
Shipping large or numerous images with a Pages/Workers deployment can exhaust its file budget. Routing sends matching paths to a different publisher — typically images to an object store — and rewrites their URLs to that store's public base.
sites:
- id: main
routing:
- match: "**/assets/**" # glob; ** crosses slashes, * does not
publisher: r2 # rewrite target inherited from the r2 publisher
publishers:
- id: r2
driver: cloudflare-r2
bucket: "{env:R2_BUCKET:-my-assets}"
account_id: "{env:CLOUDFLARE_ACCOUNT_ID}"
public_url: "{env:R2_PUBLIC_URL:-}" # optional; auto-discovered for R2 (see below)
environments:
- name: production
publish: [cf, r2] # HTML to Pages, routed images to R2
How it works:
- Build rewrites every routed image reference to the route's URL + path, so the HTML points at the object store.
- Publish partitions the tree: the route's publisher (
r2) receives only the matched files; every other publisher (cf) receives the unrouted remainder.
The route's URL is resolved as: the route's own base_url, else the target publisher's
public_url, else — on publish, for Cloudflare R2 with a CLOUDFLARE_API_TOKEN — the
bucket's auto-discovered URL (a connected custom domain, preferring the shortest, else
the r2.dev managed URL). So publish --create provisions the bucket, enables r2.dev,
and images serve from it with no URL in config; connect a custom domain later and the next
publish prefers it automatically.
A rule is inactive until a URL resolves and its publisher is deploying in the environment. With nothing resolvable, routing is a no-op: images stay co-located and the whole tree goes to the default publisher — so local builds and previews work with no object store configured.
Routing the search index
The static search index (_search/**) can be routed to the object store too, to keep it off a
Pages-style file budget:
routing:
- match: "_search/**"
publisher: r2
When routed, colophon points the browser reader at the store's URL automatically (the
search_base it emits follows the route). Because the reader loads the index with fetch() and
imports search.js as a module — neither CORS-exempt — the bucket must allow cross-origin GET;
publish --create sets that policy for you (see Provisioning).
Unrouted, the index stays on the same origin and no CORS is involved.
Generated from docs/publishing.md — edit it there.