Deploy hu13 on Cloudflare Pages (Free)
The same dist/ works on Docker/hu13 serve, nginx, and Pages. On
Pages the inbox is implemented by a Pages Function backed by R2; a
static-only upload has no working inbox.
Pages normalizes .html request paths with a 308 (for example
/issues/index.html → /issues/ and /files/a.md.html →
/files/a.md). Every generated link still reaches the same page; the
redirect is a Pages platform behavior, not a missing file. Docker/nginx
serve the exact .html path with no redirect. If byte-identical URLs
matter, put a proxy/Worker in front or accept the Pages canonical form;
_redirects status 200 cannot override these clean-URL redirects.
1. Build the site
cargo build --release
./target/release/hu13 build . -o dist \
--public-origin https://hu13.example.com
--public-origin makes the clone command, og:image, and Atom links
absolute. The build refuses output over Cloudflare's
limits (20,000 files, 25 MiB/file) and writes _headers and
_routes.json into dist/.
2. Create the R2 bucket and Pages project
npx wrangler r2 bucket create hu13-inbox
npx wrangler pages project create hu13
Bind the bucket to the Pages project as INBOX (dashboard:
Settings → Functions → R2 bindings, or the [[r2_buckets]] block in
wrangler.toml). Set the required secret:
npx wrangler pages secret put INBOX_SALT --project-name hu13
INBOX_SALT is a random string (for example openssl rand -hex 32).
The function hashes v1|<ip>|<yyyy-mm-dd>|<salt> with SHA-256; no
plaintext IP is stored. Rotating the secret unlinks old hashes.
Optional variables: INBOX_RATE_PER_HOUR (default 2),
INBOX_QUOTA (default 500), INBOX_MAX_TITLE (300),
INBOX_MAX_BODY (20000).
3. Deploy
npx wrangler pages deploy dist --project-name hu13
Wrangler uploads functions/ alongside dist/. dist/_routes.json
limits Functions to /inbox/*, so every other request is static.
Keep wrangler.toml at the repo root for the R2 binding.
What the Function implements
functions/inbox/[kind].js serves both mailboxes:
POST /inbox/issue(body) andPOST /inbox/pr-prompt(prompt,bodyaccepted as an alias)- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 400
- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤100
- license checkbox required; filled honeypot returns a fake receipt without storing
- same-origin
Origin/Referergate; no input is echoed or logged - fixed ~500 ms response delay;
Cache-Control: no-storeandX-Content-Type-Options: nosniff - one R2 object per accepted submission:
<uuid>.jsonwithtype/title/body/handle/contact/license_version/ received_at/salted_ip_hash(same shape as the Docker inbox)
Rate limits are layered: the Function has a best-effort isolate-local
bucket, but that is not global. Add a Cloudflare WAF rate-limiting
rule for /inbox/* (for example 10 requests/minute per IP) as the
durable control. The global quota is checked against the R2 bucket
before storing; R2 list consistency means a burst can briefly exceed it.
Reading submissions
npx wrangler r2 object list hu13-inbox
npx wrangler r2 object get hu13-inbox/<uuid>.json
hu13 inbox list reads the local filesystem inbox used by Docker/serve;
it does not read R2. Copy objects down if you want the terminal reader.
Limits and caveats
- Pages Free: 20,000 files and 25 MiB per file per deployment. The build enforces both; large assets belong outside git or in Git LFS.
- The function bundle must be deployed with
functions/; uploading onlydist/(drag-and-drop or a plain static host) gives a dead inbox. repo.gitis a dumb-HTTP repo: cloning works on any static host, no smart server. No GitHub dependency.- Committed
/raw/*bytes and release artifacts under/releases/<version>/*always download (Content-Disposition: attachment) on bothserveand Pages, so hostile HTML/SVG can never execute in the inbox origin. The Pages_headersrule uses one named placeholder plus one splat (/releases/:version/*): Pages silently skips rules with two splats.