Valcraven Docs
Deployment & Operations

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 use valcraven-* names and app.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 with npx wrangler login, then confirm with npx 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:setup

That 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:

  1. Verifies your wrangler login (prompts wrangler login if needed).
  2. Ensures the D1 database exists and writes its id into wrangler.toml.
  3. Ensures the R2 bucket exists.
  4. Ensures BETTER_AUTH_SECRET is set (auto-generates one if missing) and, when run interactively, prompts for optional integration secrets.
  5. Applies pending D1 migrations to the remote database.
  6. Builds with OpenNext and deploys.
  7. Health-checks the deployment (set CUSTOM_DOMAIN=app.example.com to also probe your domain).
CUSTOM_DOMAIN=app.example.com npm run deploy:cf:setup

The 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

ScriptRunsWhat it does
npm run deploy:cf:setupapps/webOne-shot provision + deploy (scripts/deploy-cloudflare.sh). First-time setup.
npm run build:cfroot or apps/webopennextjs-cloudflare build → produces apps/web/.open-next.
npm run deploy:cfroot or apps/webopennextjs-cloudflare deploy → uploads the existing .open-next. Does not build.
npm run deploy:cf:allrootbuild:cfdeploy:cf (web) → deploy:realtime. Ships both Workers.
npm run deploy:realtimerootwrangler deploy --env production in apps/realtime. Ships only the realtime Worker.
npm run preview:cfroot or apps/webopennextjs-cloudflare preview — run the OpenNext bundle locally in workerd.

deploy:cf does not build. It ships whatever is already in .open-next. Always run build:cf first, or you will silently redeploy a stale artifact (routes added since the last build will 404 in production). deploy:cf:all builds for you.

Manual deploy of the web Worker:

npm run build:cf      # OpenNext build → apps/web/.open-next
npm run deploy:cf     # upload via wrangler

What gets provisioned

The web Worker's bindings are declared in apps/web/wrangler.toml. The backing resources you create once:

ResourceBindingCreate it with
D1 databaseDB (and NEXT_TAG_CACHE_D1)npx wrangler d1 create valcraven-db
R2 bucketSTORAGE (and NEXT_INC_CACHE_R2_BUCKET)npx wrangler r2 bucket create valcraven-storage
Campaign send queueCAMPAIGN_QUEUEnpx wrangler queues create valcraven-campaign-send
Email ServiceEMAIL ([[send_email]])Onboard your sending domain in the Cloudflare dashboard
Realtime WorkerREALTIME (service binding)Deploy apps/realtime (see below)

Notes on the less-obvious ones:

  • D1binding = "DB" is what lib/db.ts looks up. A second binding, NEXT_TAG_CACHE_D1, points at the same D1 instance and backs OpenNext's tag cache for revalidateTag() / revalidatePath(). On D1 there is no bootstrap step — every table comes from migrations/, so applying migrations is mandatory (see Running migrations in prod).
  • R2binding = "STORAGE" is the native uploads path in lib/storage.ts. A second binding, NEXT_INC_CACHE_R2_BUCKET, reuses the same bucket for OpenNext's incremental cache (entries live under an incremental-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.ts falls back to the SQLite/D1 job queue — no queue needed for docker compose up. See Email campaigns.
  • Email Service — transactional email (verification, password reset, etc.) goes through Cloudflare Email Service via the EMAIL binding. 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 production

Deploy

# Both Workers, app first so the WEB binding target exists:
npm run deploy:cf:all

# …or just the realtime Worker:
npm run deploy:realtime

The 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).

On this page