better-auth-cloudflare OpenNext exampleCloudflare

Better Auth

Open-source sign-in on your own Cloudflare account.

better-auth-starter

zpg6's better-auth-cloudflare OpenNext example — open-source authentication with better-auth: anonymous, email-and-password and (once keys are stored) GitHub and Google sign-in, every session enriched with Cloudflare's geolocation and kept in Workers KV, the users in a Cloudflare D1 database, files a signed-in user uploads in R2 — running on Cloudflare Workers + Static Assets through @opennextjs/cloudflare.

The example and its wrapper, better-auth-cloudflare, are kept as they are and pinned to exact versions (better-auth-cloudflare@0.4.1, better-auth@1.7.3, @better-auth/drizzle-adapter@1.7.3); the wrapper has one maintainer, so an upgrade is a deliberate change. The database, the namespace and the bucket are provisioned by Terraform through Orun, the schema applied by migrations, and the whole is packaged as an Orun baseline: one command takes a new product from nothing to live on stage and prod, in its own repository, under its own name — in ONE landing. The session secret is generated in each environment's database, so the site signs users in with nothing typed; GitHub and Google sign-in appear when their keys are stored. What the baseline adds is everything needed to make a copy of it somebody else's, built the way cirrus-baseline is. The design, the milestones and the as-built status are in specs/epics/better-auth-starter. MIT, © 2025 Zach Grimaldi (apps/web/LICENSE).

Instantiating a product

git clone --depth 1 --branch <baseline-tag> https://github.com/sourceplane/better-auth-starter
cd better-auth-starter
orun new --blueprint repo-blueprint.yaml --out ~/sites/acme-accounts --run-hooks --resume \
  --set reponame=acme-accounts --set productname="Acme Accounts" \
  --set githuborg=acme-inc --set subdomain=<workers-dev-subdomain>

Or, once the platform's registry lists it, from the Orun console (Baselines → better-auth-starter → Build). Two phases: 01-product creates the repository and lands the whole product as ONE pull request — intent, CI, tooling, the D1, KV and R2 roots, the migrations, the site and its tests — and one convergence, per environment, applies the database, the namespace and the bucket, then the migrations, then deploys the site bound to them; 02-docs records the live deployment from probed facts. BOOTSTRAP.md is the operator guide; docs/phases/ has a page per phase.

The new repository receives the product only — the app, its D1, KV and R2 roots, its migrations and their runner, its tests, its CI, its tooling, a README of its own and its operating contract — renamed throughout to the product's name, repository, Cloudflare Worker, database, namespace and bucket names. None of this repository's machinery ships.

What makes it a baselineWhere
The console card (inputs, secrets, programme)blueprint.yaml
The build document (modules, phases, hooks)repo-blueprint.yaml
This tree's identity, and the bootstrap's task contracts.baseline/
The rename enginetooling/rebrand/rebrand.mjs
The bootstrap's helpers (deferrals, redeploy marker, work-tree strip)tooling/bootstrap/
The deployment-record rendererhooks/render-deployment-docs.sh
The product's README and context pack.baseline/product/README.md, ai/context/
The baseline's tests: contract, wiring, leak, offline bootstrap, end to endtesting/, .github/workflows/baseline.yml
Cutting a baseline-vN tag.github/workflows/tag.yml

What is here (the product)

SurfaceWhere
The sign-in page, the dashboard (user, geolocation, file upload) and the redirects between themapps/web/src/app, apps/web/src/proxy.ts
better-auth through better-auth-cloudflare: D1, KV, R2, geolocation, the anonymous and OpenAPI plugins, email and optional OAuth sign-inapps/web/src/auth
The site's name and source linkapps/web/src/lib/site.ts
The health route the deploy's smoke readsapps/web/src/app/api/health/route.ts
Workers build, bindings and deploy contractapps/web/next.config.mjs, open-next.config.ts, wrangler.template.jsonc + wiring.fixture.json (→ the committed wrangler.jsonc), component.yaml
The D1 database, the KV namespace and the R2 bucket (stage, prod)infra/terraform/cloudflare-d1, cloudflare-kv, cloudflare-r2
The schema (migrations) and the runner that applies them over the D1 REST APIpackages/db, run by infra/db-migrate
Teststests/web (the providers, the generated secret, the health route, the redirects), tests/db (the migrations through the runner, into real SQLite)

Getting started

pnpm install
pnpm run wire:fixture   # render apps/web/wrangler.jsonc from the offline fixture
pnpm typecheck
pnpm lint
pnpm test
pnpm build              # next build + opennextjs-cloudflare build → apps/web/.open-next
pnpm --filter @app/web db:migrate:local   # better-auth's tables and the auth secret in wrangler's local D1
pnpm --filter @app/web dev                # http://localhost:3000 — the local D1, KV and R2 through wrangler's platform proxy
pnpm --filter @app/web preview            # wrangler dev over the built Worker, same local bindings

Deploying

The Orun pipeline (.github/workflows/ci.yml) plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME. Link the repository to an Orun Cloud workspace (orun cloud link), set execution.state.workspace to it, connect Cloudflare in the workspace, and every merge to main then applies the D1 database, the KV namespace and the R2 bucket, migrates the database and deploys better-auth-starter-web-stage, then -prod, each smoke-tested on /api/health. A pull request verifies the same lanes and deploys nothing. A manual run of ci.yml (Run workflow on main, or Orunbase's Deploy button) redeploys the components it names.

The deploy needs nothing typed: the auth secret is generated in each environment's database by the migrations, and the sign-in page works with no provider. R2 must be enabled on the Cloudflare account once (the dashboard asks for a payment method even on the free tier); until it is, the R2 root's apply fails.

Custom domain

The Worker answers on workers.dev from its first deploy. To serve it on a domain of your own, attach the domain to the Worker in Cloudflare (Workers & Pages → the Worker → Settings → Domains & Routes) and replace better-auth-starter.example.com in intent.yaml's BASE_DOMAIN. better-auth takes the origin each request arrives on, so sign-in works on the new domain without changing a line; set BETTER_AUTH_URL only to pin one origin (an OAuth app's callback URL must match it).

Sign-in providers

Anonymous and email-and-password sign-in work with nothing configured. GitHub or Google sign-in appears once a provider's client id and secret are both set — GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET — in apps/web/.dev.vars locally. The OAuth app's callback URL is <origin>/api/auth/callback/<provider>. No mail sender is configured, so email addresses are not verified.

How it maps to the example's Cloudflare setup

The exampleHere
wrangler.toml with placeholder database, namespace and bucket idswrangler.template.jsonc → wrangler.jsonc, rendered per environment from wiring documents (tooling/wire); the binding names (DATABASE, KV, R2_BUCKET) are the example's
better-auth-cloudflare: file:../../ (the repository's own build)better-auth-cloudflare@0.4.1 from npm, exact, with better-auth and the Drizzle adapter at 1.7.3
drizzle-kit migrations, wrangler d1 migrations applypackages/db migrations — 010_auth (the three drizzle files' end state), 020_app_secrets — applied by infra/db-migrate before the Worker deploys, and locally by db:migrate:local
a database, a namespace and a bucket created by hand in the dashboardinfra/terraform/cloudflare-{d1,kv,r2}, applied by Orun before the migrations and the Worker
BETTER_AUTH_SECRET typed into .dev.varsgenerated once per database by 020_app_secrets; a BETTER_AUTH_SECRET secret wins
BETTER_AUTH_URL / BETTER_AUTH_TRUSTED_ORIGINS varsoptional; unset, the base URL and the trusted origin are the origin the request came to
anonymous sign-in onlyanonymous, email and password, and GitHub/Google when their keys are stored
Next.js 15 middleware.tsNext.js 16 proxy.ts, the same self-request to /api/auth/get-session
next build ignoring lint and type errorspnpm lint and pnpm typecheck clean; the build checks types
[placement] mode = "smart"not set: D1, KV and R2 are in the Worker's own account

How the data reaches the Worker

infra/terraform/cloudflare-d1 ──apply──► WIRING_CLOUDFLARE_D1 ─┬─► infra/db-migrate  apply: packages/db's runner → POST …/d1/database/<id>/query
infra/terraform/cloudflare-kv ──apply──► WIRING_CLOUDFLARE_KV ─┤        (better-auth's tables, the generated auth secret)
infra/terraform/cloudflare-r2 ──apply──► WIRING_CLOUDFLARE_R2 ─┴─► apps/web  build: tooling/wire/live.mjs → wrangler.jsonc with the ids
                                                                          (dev, verify-only, renders the fixture) → next build → OpenNext → deploy

Each reader declares an enforced dependsOn on what it reads on a push to main (advisory on a pull request), so one convergence per environment runs the three roots, then the migrations, then the Worker. wrangler.jsonc is committed as the fixture render of wrangler.template.jsonc, because the deploy lane's verify-app-structure step checks the config exists before it builds.

CI

WorkflowWhat it runsShips to a product
ci.ymlthe Orun pipeline, and the only workflow a product carries: orun plan --changed, then one job per lane. A pull request runs the verify lanes (packages type-checked, linted, tested and built, both test suites, the site built and dry-deployed per environment), except the bootstrap's own landings (orun/BASE-*), where plan alone is the check; a merge applies the D1 database, the namespace and the bucket, migrates, then deploys stage → prod. It plans nothing while intent.yaml names the placeholder workspace ws_CHANGE_ME, as this repository does. A manual run (Orunbase's Deploy button, or Run workflow on main) plans the environment and components it names under the push-to-main trigger, so they deploy as a merge deploys themyes
baseline.ymlthe baseline's own tiers: contract tests, the wiring path, the blueprint read by the pinned orun, the leak gate, the offline bootstrap with the product's own plan, the product built in the job tree, migrated, dry-deployed and served — every sign-in flow and an R2 round trip with no provider connected, then a GitHub sign-in started with keys handed over as the deploy hands themno
tag.ymlcuts a baseline-vN tag (manual)no