Cloudflare Workers and D1
Run the API at the edge, with content in D1.
The edge profile: the API runs on Cloudflare Workers, data lives in D1. Same code as the self-hosted Bun profile; only the connector binding differs. Auth (better-auth) uses the same D1 database; its tables are created on first request alongside the content schema.
Prerequisites
- A Cloudflare account (free tier is enough)
bun installdone at the repo root
1. Authenticate wrangler
cd apps/worker
bunx wrangler login2. Create the D1 database
bunx wrangler d1 create opencmsCopy the database_id from the output into apps/worker/wrangler.toml.
The schema needs no migration step: the Worker creates the fixed content
tables and the auth tables on first request (both inits are idempotent),
and content types never require DDL afterwards. Field indexes are created
at runtime by ensureIndexes.
3. Set the auth secret
openssl rand -base64 32 | bunx wrangler secret put BETTER_AUTH_SECRET4. Build the admin and deploy
The Worker ships the admin SPA as static assets, so build it first:
bun run --cwd ../admin build
bunx wrangler deployWrangler prints your URL, e.g. https://opencms-api.<account>.workers.dev.
The admin UI is served at that URL's root; /api/*, /health and /mcp
hit the Worker directly (run_worker_first). MCP is the same Hono app the
dedicated apps/mcp Worker serves at https://mcp.opencms.dev/mcp; here it
is mounted on the API origin so self-host and this Worker both speak MCP
without a second process. See MCP.
5. Set BETTER_AUTH_URL
better-auth needs a canonical public URL for cookies and CSRF/origin checks.
After the first deploy, put the printed URL into apps/worker/wrangler.toml
and redeploy:
[vars]
BETTER_AUTH_URL = "https://opencms-api.<account>.workers.dev"Use your custom domain here instead if you attached one. Then:
bunx wrangler deployopencms setup writes this var automatically when you pass a custom domain.
On a plain *.workers.dev host you set it yourself after the first deploy
(the subdomain is unknown until Wrangler assigns it).
If the var is unset, the Worker falls back to the current request's origin. Still set the var: cookies and trusted origins stay correct across redirects and custom domains, and older builds without the fallback can hang forever on auth POSTs (including API key mint in the admin).
6. Bootstrap the admin and smoke test
Open the deployed URL: the admin UI shows the first-run setup screen and creates the admin account there. Or bootstrap over HTTP:
API=https://opencms-api.<account>.workers.dev
curl $API/health
# 1. Bootstrap: the first user is the admin
curl -X POST $API/api/auth/sign-up/email \
-H 'content-type: application/json' \
-c cookies.txt \
-d '{"email":"you@example.com","password":"a-strong-password","name":"You"}'
# 2. Create the schema (admin session)
curl -X POST $API/api/content-types \
-H 'content-type: application/json' \
-b cookies.txt \
-d '{"name":"article","label":"Article","fields":[{"name":"title","kind":"text","required":true},{"name":"views","kind":"number","indexed":true}]}'
# 3. Mint an API key for machines (admin session; Origin header required
# on auth endpoints when authenticating with cookies outside a browser)
curl -X POST $API/api/auth/api-key/create \
-H 'content-type: application/json' \
-H "Origin: $API" \
-b cookies.txt \
-d '{"name":"ci","metadata":{"role":"editor"}}'
# 4. Write content with the key
curl -X POST $API/api/content/article \
-H 'content-type: application/json' \
-H 'x-api-key: <key from step 3>' \
-d '{"status":"published","data":{"title":"Hello from the edge"}}'
# 5. Published content is readable without auth
curl "$API/api/content/article?sort=createdAt:desc"Local development against workerd
cd apps/worker
echo 'BETTER_AUTH_SECRET=local-dev-secret-never-deploy-0123456789' > .dev.vars
bunx wrangler devwrangler dev runs the Worker in workerd with a local D1. Note that the
test suite already exercises real workerd D1 via Miniflare
(packages/connector-d1/test/conformance.test.ts), so bun test at the
repo root is the primary verification loop; wrangler dev is for manual
poking.
Access model (since M3)
- Anonymous requests can read published entries only.
- Editors (sessions or API keys with the editor role) manage content.
- Admins additionally manage content types, users and API keys.
- User accounts are created by admins (
POST /api/auth/admin/create-user); public signup only ever works for the very first user.
Troubleshooting
Admin API keys stuck on "Minting…". POST /api/auth/api-key/create
never finishes. Almost always a missing or wrong BETTER_AUTH_URL. Set
[vars] BETTER_AUTH_URL to the exact Worker URL (scheme + host, no path),
redeploy, hard-refresh the admin, and mint again. Cookie curl calls to
/api/auth/* also need Origin: $API matching that URL.
Sign-in / sign-up fails with CSRF or origin errors. Same fix: the
BETTER_AUTH_URL host must match what the browser uses.
Caveats (current milestone)
- One Worker, one D1 database. Multi-tenant setups deploy one Worker per site for now.
- The official MCP hostname
mcp.opencms.devisapps/mcp, a second Worker on the same D1. Until that custom domain is bound,/mcpon this API Worker is the working endpoint.