Deploying to Cloudflare Workers
Provision and ship Valcraven to Cloudflare Workers — D1, R2, Queues, the realtime Worker, and a custom domain.
Valcraven deploys to Cloudflare Workers via OpenNext. The Next.js app is built into an OpenNext bundle and uploaded with wrangler; the database is D1, uploads live in R2, campaign sends drain through a Cloudflare Queue, and a separate realtime Worker hosts the AI chat agent. Everything is wired in apps/web/wrangler.toml and apps/realtime/wrangler.toml — you provision the backing resources once, then deploy.
The template ships with Valcraven's own resource names (
valcraven,valcraven-db,valcraven-storage,valcraven.oqodo.com). If you scaffolded with/valcraven-create, those were already rewritten for your project. The examples below usevalcraven-*names andapp.example.com— substitute your own.
Prerequisites
- A Cloudflare account on the Workers Paid plan (D1 and R2 require it).
wrangler— bundled as a dev dependency. Authenticate once withnpx wrangler login, then confirm withnpx wrangler whoami.- For a custom domain, the hostname's zone must be on the same Cloudflare account so Workers can manage the proxied DNS record.
The easy button
From apps/web/:
npm run deploy:cf:setupThat runs scripts/deploy-cloudflare.sh, which is idempotent — safe on a brand-new project (it provisions everything) or an existing one (it skips what already exists, then ships the current code). In order it:
- Verifies your
wranglerlogin (promptswrangler loginif needed). - Ensures the D1 database exists and writes its id into
wrangler.toml. - Ensures the R2 bucket exists.
- Ensures
BETTER_AUTH_SECRETis set (auto-generates one if missing) and, when run interactively, prompts for optional integration secrets. - Applies pending D1 migrations to the remote database.
- Builds with OpenNext and deploys.
- Health-checks the deployment (set
CUSTOM_DOMAIN=app.example.comto also probe your domain).
CUSTOM_DOMAIN=app.example.com npm run deploy:cf:setupThe script does not create the Cloudflare Queue or set up the realtime Worker — those are covered below. The rest of this page unpacks each step so you can run it by hand or wire it into CI.
The deploy scripts
| Script | Runs | What it does |
|---|---|---|
npm run deploy:cf:setup | apps/web | One-shot provision + deploy (scripts/deploy-cloudflare.sh). First-time setup. |
npm run build:cf | root or apps/web | opennextjs-cloudflare build → produces apps/web/.open-next. |
npm run deploy:cf | root or apps/web | opennextjs-cloudflare deploy → uploads the existing .open-next. Does not build. |
npm run deploy:cf:all | root | build:cf → deploy:cf (web) → deploy:realtime. Ships both Workers. |
npm run deploy:realtime | root | wrangler deploy --env production in apps/realtime. Ships only the realtime Worker. |
npm run preview:cf | root or apps/web | opennextjs-cloudflare preview — run the OpenNext bundle locally in workerd. |
deploy:cfdoes not build. It ships whatever is already in.open-next. Always runbuild:cffirst, or you will silently redeploy a stale artifact (routes added since the last build will 404 in production).deploy:cf:allbuilds for you.
Manual deploy of the web Worker:
npm run build:cf # OpenNext build → apps/web/.open-next
npm run deploy:cf # upload via wranglerWhat gets provisioned
The web Worker's bindings are declared in apps/web/wrangler.toml. The backing resources you create once:
| Resource | Binding | Create it with |
|---|---|---|
| D1 database | DB (and NEXT_TAG_CACHE_D1) | npx wrangler d1 create valcraven-db |
| R2 bucket | STORAGE (and NEXT_INC_CACHE_R2_BUCKET) | npx wrangler r2 bucket create valcraven-storage |
| Campaign send queue | CAMPAIGN_QUEUE | npx wrangler queues create valcraven-campaign-send |
| Email Service | EMAIL ([[send_email]]) | Onboard your sending domain in the Cloudflare dashboard |
| Realtime Worker | REALTIME (service binding) | Deploy apps/realtime (see below) |
Notes on the less-obvious ones:
- D1 —
binding = "DB"is whatlib/db.tslooks up. A second binding,NEXT_TAG_CACHE_D1, points at the same D1 instance and backs OpenNext's tag cache forrevalidateTag()/revalidatePath(). On D1 there is no bootstrap step — every table comes frommigrations/, so applying migrations is mandatory (see Running migrations in prod). - R2 —
binding = "STORAGE"is the native uploads path inlib/storage.ts. A second binding,NEXT_INC_CACHE_R2_BUCKET, reuses the same bucket for OpenNext's incremental cache (entries live under anincremental-cache/key prefix, isolated from user uploads). - Campaign send queue — scalable campaign sends enqueue recipient batches and a consumer on the same Worker drains them with retry/backoff. Off Workers (local dev) there is no binding, so
lib/campaign-send.tsfalls back to the SQLite/D1 job queue — no queue needed fordocker compose up. See Email campaigns. - Email Service — transactional email (verification, password reset, etc.) goes through Cloudflare Email Service via the
EMAILbinding. Onboard your domain at Email Sending in the dashboard first. See Email.
Then set your origin and secrets. In wrangler.toml [vars], BETTER_AUTH_URL and APP_URL must be your deployed origin — Better Auth uses BETTER_AUTH_URL as its base URL and a trusted origin, and email/Stripe links use APP_URL. BETTER_AUTH_SECRET is a required secret. The full breakdown of vars vs secrets is in Secrets & env.
Custom domain
Bind a hostname directly to the Worker with a custom_domain route in wrangler.toml (top level), then deploy. Cloudflare creates and manages the proxied DNS record for you.
routes = [
{ pattern = "app.example.com", custom_domain = true }
]Cert provisioning and DNS can take up to a minute on the first bind. Adding a custom domain disables the *.workers.dev URL by default; set workers_dev = true in wrangler.toml to keep both.
Web deploys automatically; realtime deploys by hand
Once your repo is connected to Cloudflare Workers Builds, every push to main builds and deploys the web Worker automatically — no manual deploy:cf needed for routine changes. The realtime Worker is not part of that pipeline: deploy it yourself with npm run deploy:realtime whenever apps/realtime changes. See CI with Workers Builds for the full picture.
The realtime Worker
The AI chat runs over a live WebSocket to a per-user AppAgent Durable Object hosted by the realtime Worker (apps/realtime) — a small standalone Worker that owns Durable Objects, alarms, and WebSockets. In production both Workers serve the same hostname: the app Worker owns app.example.com, and the realtime Worker owns the more-specific app.example.com/agents/* and app.example.com/realtime/* routes in the same Cloudflare zone, so the Better Auth session cookie flows. The realtime Worker reaches the app Worker over an internal WEB service binding — session validation and persistence never take a public hop.
Its production config lives under [env.production] in apps/realtime/wrangler.toml (the routes, the WEB service binding, WEB_ORIGIN, the redeclared Durable Object bindings + migrations, and the Workers AI binding — wrangler named envs do not inherit those from the top level). See The realtime Worker for the architecture.
Secrets (one-time)
AGENTS_INTERNAL_SECRET authenticates the agent's server-to-server calls and must be the same value on both Workers. ANTHROPIC_API_KEY drives the chat loop on the realtime Worker.
# Same AGENTS_INTERNAL_SECRET on BOTH Workers:
SECRET=$(openssl rand -base64 32)
printf '%s' "$SECRET" | (cd apps/web && npx wrangler secret put AGENTS_INTERNAL_SECRET)
printf '%s' "$SECRET" | (cd apps/realtime && npx wrangler secret put AGENTS_INTERNAL_SECRET --env production)
# Anthropic key on the realtime Worker:
cd apps/realtime && npx wrangler secret put ANTHROPIC_API_KEY --env productionDeploy
# Both Workers, app first so the WEB binding target exists:
npm run deploy:cf:all
# …or just the realtime Worker:
npm run deploy:realtimeThe Durable Object SQLite migrations apply on the first deploy and are idempotent on redeploys. Deploying the realtime Worker is purely additive — it doesn't touch the app Worker.
Verify
curl https://app.example.com/api/health # app Worker health snapshot
curl https://app.example.com/agents/health # realtime Worker → {"ok":true}/api/health returns a structured snapshot with a top-level status (ok / degraded / down) and a checks.db block — the HTTP status is 200 when serving and 503 when the DB is unreachable. A healthy response confirms the D1 binding resolves and Better Auth initialised (i.e. BETTER_AUTH_URL + BETTER_AUTH_SECRET are present). Then sign in and open the chat — you should get a streaming WebSocket session served by the realtime Worker.
Watch live logs with npx wrangler tail valcraven (web) or cd apps/realtime && npx wrangler tail --env production (realtime).