Self-hosting
One Cloudflare Worker, one R2 bucket, two DNS records. About ten minutes the first time; free tier for any personal use.
What you need
- A domain on Cloudflare (DNS managed there — the zone must be active in your account).
- A Cloudflare account with R2 enabled (R2 → Get started; Cloudflare asks for a payment method even on the free tier).
- bun ≥ 1.3 and a clone of this repository.
wrangleris a devDependency; never install it globally.
Throughout, <zone> is your domain, for example example.dev. Pages will be served at https://<hash>.<zone> and the API at https://api.<zone>.
1. An API token for wrangler
My Profile → API Tokens → Create Token → start from Edit Cloudflare Workers, then:
- add
Account · Workers R2 Storage · EditandZone · DNS · Edit. The template already includes Workers Scripts, Workers Routes, and Workers KV Storage; if you build the token by hand instead, add those too (wrangler deleteneeds KV read — it checks for preview namespaces before removing a Worker). - Account Resources: Include → your account
- Zone Resources: Include · Specific zone →
<zone>
Copy the token into apps/worker/.env (gitignored):
CLOUDFLARE_API_TOKEN=<token>CLOUDFLARE_ACCOUNT_ID=<account id> # Workers & Pages overview, right-hand columnbunx wrangler whoami from apps/worker must show your account. If it lists several, CLOUDFLARE_ACCOUNT_ID is what pins the right one.
2. Three edits in wrangler.jsonc
apps/worker/wrangler.jsonc ships pointing at the maintainer’s deployment. Change the three lines marked EDIT; the other three are optional and belong to living names (EDIT 4), hosted accounts (EDIT 5) and the paid tier (EDIT 6). (wrangler.production.jsonc next to it is the maintainer’s filled-in copy for deploying handbill.dev; it is not for you.)
wrangler.staging.jsonc is the maintainer’s second copy, a separate Worker on handbill-staging.dev with its own bucket and KV namespaces that .github/workflows/deploy-worker-staging.yml deploys from main; it is not for you either. Self-hosting needs one deployment, and this file is only here so a change can be seen running before it reaches production.
"vars": { "ZONE": "<zone>" },"routes": [ { "pattern": "api.<zone>/*", "zone_name": "<zone>" }, { "pattern": "*.<zone>/*", "zone_name": "<zone>" }],"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "handbill" }]Keep the Worker name and the bucket name unless you have a reason not to.
3. DNS records
Two records on <zone>, both proxied (orange cloud). The IP is a placeholder — the Worker route answers, not an origin:
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | api | 192.0.2.1 | on |
| A | * | 192.0.2.1 | on |
Universal SSL covers *.<zone> automatically. It does not cover a second level (*.*.<zone>), which is why pages live directly under the zone.
Create the records before deploying: a Worker route can only attach to a hostname that has a proxied record.
4. Bucket, secret, deploy
From apps/worker:
bun installbunx wrangler r2 bucket create handbill
# The token the CLI will send. Keep it; you need it in step 5.TOKEN=$(openssl rand -hex 32)printf '%s' "$TOKEN" | bunx wrangler secret put PUBLISH_TOKEN
bunx wrangler deployMAX_BYTES is an optional var (bytes, default 5 MB) if you want a different cap.
5. Point the CLI at it
mkdir -p ~/.config/handbill(umask 077 && printf '{ "endpoint": "https://api.<zone>", "token": "%s" }\n' "$TOKEN" > ~/.config/handbill/config.json)Environment variables work too and win over the file: HANDBILL_ENDPOINT, HANDBILL_TOKEN.
Moving a machine from the hosted deployment to your own is both fields, not just endpoint. If handbill login has ever run here, the file holds a hosted key and a mintedAt recording where it came from; a minted key is only ever sent back to the deployment that minted it, so one left beside your own endpoint is refused in one sentence naming both. Run handbill logout first — it gives the hosted key back and takes mintedAt with it — then write the file above. A PUBLISH_TOKEN of your own is not pinned to anything: nobody issued it.
Then:
npm i -g handbillhandbill doctordoctor checks the config, the token, GET /v1/health, that the token is accepted, and that the wildcard certificate is valid. Each check prints pass/fail and one sentence of what to do.
6. Prove it
curl -s https://api.<zone>/v1/health# {"ok":true,"mode":"secret","zone":"<zone>"}
printf '<!doctype html><title>hello</title><p>hi' > /tmp/hi.htmlhandbill /tmp/hi.html# https://<hash>.<zone>
curl -si "https://<hash>.<zone>/" | head -8# HTTP/2 200 · content-type: text/html; charset=utf-8# x-robots-tag: noindex, nofollow · cache-control: public, max-age=31536000, immutable
handbill listhandbill remove <hash>Living names (optional)
A hash link is the bytes, forever. An alias is a name you can point somewhere else: plan.<zone> serves whatever it currently points at, while every hash link ever handed out keeps working.
Aliases are guessable by construction. plan, notes, roadmap — someone who knows your zone can try names until one answers. A hash is 48 random bits and a name is a word, so put behind a name only what you would not mind a stranger reading. This is why the feature is off until you switch it on: no binding, no aliases.
From apps/worker:
bunx wrangler kv namespace create ALIASESUncomment the kv_namespaces line in wrangler.jsonc (marked EDIT 4), paste in the id it printed, and bunx wrangler deploy again. Then:
handbill alias plan <hash> # https://plan.<zone>handbill alias plan <hash> --open # the same, then open it in the browser
curl -si "https://plan.<zone>/" | head -4# HTTP/2 200 · cache-control: public, max-age=60
handbill alias list # https://plan.<zone> <hash>handbill alias remove planThe same calls by hand are PUT and DELETE /v1/aliases/plan (body {"hash":"<hash>"}), GET /v1/aliases for all of them and GET /v1/aliases/plan for one, with the bearer token, as the spec at /v1/openapi.json describes them.
The page is served at the name, not redirected to the hash — the reader’s address bar keeps the name. It is cached for a minute rather than a year, so re-pointing an alias is visible almost immediately; a hash page is unaffected either way. One KV habit to know: a name answers the moment it is set, but handbill alias list can take up to a minute to show a fresh one, because KV’s key listing is eventually consistent while a read by key is not. “No aliases set.” right after handbill alias plan … printed a URL is that lag, not a failure — ask again. Names are one DNS label: lowercase letters, digits and inner hyphens, up to 63 characters, and neither api nor anything shaped like a hash (nor list or remove, which the CLI takes as subcommands). Without the binding, every /v1/aliases route answers 404 {"_tag":"NotFound"}, handbill alias says “Aliases are off on this deployment”, and <name>.<zone> serves “Nothing here”.
Hosted accounts (optional)
Everything above runs on one shared PUBLISH_TOKEN: one operator, one owner. Binding an ACCOUNTS KV namespace (EDIT 5 in wrangler.jsonc) switches the Worker to per-account keys instead — POST /v1/keys mints one per GitHub account, /v1/health reports "mode":"accounts", and pages are owned by gh:<id> rather than by the operator. Without the binding none of that exists and this section does not apply.
Binding ACCOUNTS brings three things at once, all in that one namespace: the key records, the per-owner page index that makes list and remove account-scoped, and the quota counters below. Aliases stay operator-only, so a hosted key cannot set, read or enumerate names.
Quotas. The numbers are per tier, and there are two of them, one table in apps/worker/src/quotas.ts: free allows 25 pages a day and 250 MB stored, paid 250 pages a day and 5 GiB. The page-size cap (MAX_BYTES, 5 MB) is not a tier row — it applies to both. Over either quota, publishing answers 429 {"_tag":"QuotaExceeded", ...} naming the limit, what it allows, and — for the daily one — when it resets (the next UTC midnight). Unpublishing gives the stored bytes back; the day’s page count is not refunded. An account’s tier is a field on its key record, moved either by the billing webhook below or by hand with handbill admin tier <owner> <free|paid>; every key the owner holds moves together, and a key minted afterwards inherits it. Nothing on the read path consults the tier, so a lapse lowers a write quota and breaks no link. Without the ACCOUNTS binding nothing is counted at all: the operator pays their own bill.
These are ceilings, not exact numbers. The counters live in Workers KV, which is eventually consistent, so a burst of parallel publishes can read the same stale count and go past 25 before it catches up — by roughly whatever rate the caller can sustain. The per-IP WAF rate limit is what bounds that rate, which is why WAF.md’s rule 1 is required rather than recommended before a zone hosts strangers, and why quotas alone are a cost ceiling rather than a flood defence. Exact counters would mean Durable Objects, which 0.3 deliberately does not have. WAF.md also carries the abuse runbook, the KV write budget a hosted publish now costs, and how to reset a counter that has drifted.
Takedown. ADMIN_TOKEN is an optional second secret, and the only thing that can kill a published link:
printf '%s' "$(openssl rand -hex 32)" | bunx wrangler secret put ADMIN_TOKENHANDBILL_ADMIN_TOKEN=<that token> handbill admin takedown https://<hash>.<zone>It is never a publishing key: a user key gets 401 here, and this token cannot publish. Without the secret the route is not there at all (404), like the alias routes without their binding. The page stops being served, leaves its owner’s list, and its bytes go back to that owner’s quota; a taken-down hash then 404s exactly like one that was never published. Taking a page down does not touch the key that published it — revoking that is a separate act, described in WAF.md.
Before a production zone. Tenant isolation and quotas ship as of M16, but hosting strangers is more than code: you also need terms saying what may not be published and what you can see, and an address a report can arrive at. The maintainer’s are terms and acceptable use and reporting abuse — written for handbill.dev, so adapt rather than copy — and DRILL.md is the script that proves takedown, revocation and the kill switch work end to end, which is worth running once on your own zone before strangers do it for you. Until you have all three, ACCOUNTS is for a deployment whose users you know.
The kill switch is symmetrical: remove the ACCOUNTS binding and redeploy, and the Worker is back on PUBLISH_TOKEN with every published page still serving — accounts were never on the read path. The namespace is untouched by the flip, so putting the binding back brings the keys, the index and the counters with it; DRILL.md §D rehearses both halves and times them.
Paid tier (optional)
Only if you are selling something. Four settings turn the billing surface on, and they are additive exactly like the two sections above — without them there is nothing to buy and the routes are not there.
Two vars (EDIT 6 in wrangler.jsonc) and two secrets:
# The product to sell, and — for the sandbox only — the API it lives on.# "POLAR_PRODUCT_ID": "<uuid from the Polar dashboard>",# "POLAR_API": "https://sandbox-api.polar.sh"
printf '%s' '<organisation access token>' | bunx wrangler secret put POLAR_ACCESS_TOKENprintf '%s' '<endpoint secret, whsec_ prefix included>' | bunx wrangler secret put POLAR_WEBHOOK_SECRETPOLAR_ACCESS_TOKEN and POLAR_PRODUCT_ID are both or neither: with them, POST /v1/account/checkout creates a session — handbill account --upgrade is what a user runs — and the Worker stamps it with the owner the presented key resolved to, so a checkout URL can only ever pay for the account that asked for it. Without either, that route answers 404 and handbill account --upgrade says the deployment sells nothing.
POLAR_WEBHOOK_SECRET is what POST /v1/billing/webhook verifies deliveries against — Standard Webhooks: HMAC-SHA256 over <id>.<timestamp>.<body>, with a five-minute timestamp window. Paste the endpoint secret whole, whsec_ prefix included; the prefix is how the Worker knows to base64-decode the key rather than take the string’s bytes. Without the secret the route answers 404 and no tier is ever flipped. Everything that verifies is 202, including an event that decides nothing — the provider retries on anything else, and there is nothing to retry when the answer would not change. A replayed delivery is ignored: the record keeps the event time of the last flip, and only a strictly newer event writes.
handbill admin tier <owner> paid is the manual override for a delivery that never landed, and the support tool. It needs ADMIN_TOKEN, not a Polar anything, and it always wins over a replay — so a webhook that arrives late cannot undo it.
The comments in wrangler.jsonc are the reference for all four; this section is the summary.
Scripting against it
Your instance describes itself. GET https://api.<zone>/v1/openapi.json is the OpenAPI 3.1 document generated from the same contract the Worker implements and the CLI is built from, so it can never describe an API your deployment does not serve. It needs no token and is cacheable — point a client generator at it, or read it and write the four curls by hand. https://api.<zone>/docs renders that same document as a browsable reference; it pulls the viewer from a CDN, so it wants an internet connection, while the spec itself is served by the Worker alone. Neither route exposes anything you published.
The apex, optional
The five steps leave https://<zone> itself alone: the pages route is *.<zone>/*, which does not match the bare zone, so the apex is free for whatever you like — an existing site, a parked page, nothing at all. On handbill.dev it is the project site, apps/web: a static Astro build deployed as a second Worker (apps/web/wrangler.jsonc — assets only, no script, no bindings) on the route <zone>/*, which needs a proxied DNS record for @ just as api and * need theirs. The maintainer’s copy is deployed from main by .github/workflows/deploy-web.yml; to put it on your own zone, change the two handbill.dev in that wrangler.jsonc and the site in apps/web/astro.config.ts — it becomes every page’s canonical URL and the sitemap — then run bun run build && bunx wrangler deploy from apps/web. www.<zone> is one label under the wildcard, so it answers “Nothing here” like any name you have not set.
Deploy to Cloudflare button
The button in the README clones the repo into your account and provisions the Worker and the bucket from wrangler.jsonc. It cannot know your zone, so after it finishes you still do steps 2 (edit the routes and ZONE in the generated repo), 3, and the secret put in step 4.
Limits and cost
- Documents: one self-contained HTML file per link, 5 MB cap by default.
- Aliases: optional, and free at any personal scale — KV’s free tier is 100 000 reads and 1 000 writes a day, and a cached alias page costs neither.
- Free tier: Workers 100 000 requests/day; R2 10 GB stored, 1 million writes and 10 million reads per month. A published page is one object; a read is one R2 get on cache miss — Cloudflare’s cache serves the rest because responses are
immutable. - Nothing phones home. Your account, your bucket, your token.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
wrangler deploy rejects a route | The proxied DNS record for that hostname does not exist yet (step 3). |
curl to api.<zone> returns Cloudflare 522/1016 | Record exists but is not proxied, or the route pattern does not match api.<zone>/*. |
handbill exits with 401 | Token mismatch: what is in config.json is not what wrangler secret put stored. |
400 hash_mismatch | The file changed between hashing and upload, or something rewrote the bytes in transit. Publish again. |
413 | Over MAX_BYTES. Inline less, or raise the var. |
429 QuotaExceeded | A hosted account’s daily page count or stored bytes. Wait for the reset it names, or unpublish something. Self-hosted deployments never see it. |
handbill admin takedown says the endpoint has no takedown route | No ADMIN_TOKEN secret on that deployment: wrangler secret put ADMIN_TOKEN and deploy again. |
handbill alias says “Aliases are off on this deployment” | No ALIASES binding: create the namespace, uncomment kv_namespaces, deploy again. |
<name>.<zone> says “Nothing here” | No ALIASES binding, the name is not set, or it points at a hash you have unpublished. |
Certificate error on https://<hash>.<zone> | Universal SSL for the wildcard can take a few minutes after the * record is created. |
whoami shows the wrong account | Set CLOUDFLARE_ACCOUNT_ID in .env. |
Updating
git pullbun installcd apps/worker && bunx wrangler deployThe API is versioned under /v1 and does not break inside a major version; published links never change.
Removing it
bunx wrangler delete removes the Worker and its routes (a hand-made token without KV read still deletes the Worker but then reports an authentication error). Empty and delete the bucket in the dashboard, delete the ALIASES namespace if you created one, delete the two DNS records, and remove ~/.config/handbill/config.json. Every published link stops resolving.