saasprokit
Deployment

Deploy to Cloudflare Workers

Deploying apps/web to Cloudflare Workers via the pre-wired OpenNext adapter — accounts and env vars, not code

The cost-efficient-at-scale path. apps/web already ships everything needed to run on Cloudflare Workers through the OpenNext adapter (@opennextjs/cloudflare) on the workerd runtime — wrangler.jsonc, open-next.config.ts, the cf:build/deploy/preview scripts, an R2-backed incremental cache, and a runtime-detected Prisma driver adapter are all committed to the repo. You provision accounts and set env vars; you don't write the integration. Budget ~30–45 minutes.

Complete the shared prerequisites first. If you want the simplest path, use Vercel instead — Cloudflare is cheaper at scale but carries the workerd caveats below.

1. Run the host setup

pnpm install
pnpm run setup

Pick Cloudflare Workers when prompted. This keeps apps/web/wrangler.jsonc, apps/web/open-next.config.ts, and the @opennextjs/cloudflare/wrangler devDependencies, and prunes the Vercel/Docker-only files. Setup is one-way — re-clone the kit if you switch hosts later.

After setup, read the rewritten apps/web/wrangler.jsonc. Worker name and the R2 cache bucket are rebranded to your slug (<slug>-web-cache). Named envs such as env.demo are stripped — do not copy them from the kit repo.

Run every wrangler command from apps/web (or pnpm --filter app …).

2. Provision the R2 buckets

The shipped wrangler.jsonc wires an R2-backed incremental cache (via open-next.config.ts) so revalidatePath/ISR survives isolate recycling. Create that bucket once per Cloudflare account:

cd apps/web
wrangler r2 bucket create <slug>-web-cache   # name from wrangler.jsonc after setup

If you're also using R2 for file uploads (step 4), create a separate bucket for that — its name is whatever you set for AWS_S3_BUCKET.

3. Secrets

Runtime secrets go through Wrangler, never committed to wrangler.jsonc:

cd apps/web
wrangler secret put DATABASE_URL          # Neon POOLED (-pooler) endpoint — workerd has no raw TCP
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put RESEND_API_KEY
wrangler secret put AWS_ACCESS_KEY_ID
wrangler secret put AWS_SECRET_ACCESS_KEY
wrangler secret put AWS_S3_ENDPOINT      # R2's S3-compatible endpoint: https://<account-id>.r2.cloudflarestorage.com
# ...and the OAuth secrets if used

AWS_S3_ENDPOINT is a secret because the R2 endpoint embeds your Cloudflare account ID — it's already declared in packages/storage/keys.ts, so there's nothing to add there.

4. Storage: R2 (S3-compatible) or AWS S3

There's no filesystem on Workers, so STORAGE_PROVIDER=local can't work — the shipped wrangler.jsonc already sets STORAGE_PROVIDER=s3 under vars. Cloudflare R2 is S3-compatible, so pointing at it is just setting AWS_S3_ENDPOINT to your account's R2 endpoint (step 3). Leave AWS_S3_ENDPOINT unset to use real AWS S3 instead.

5. Non-secret vars and the build environment

wrangler.jsonc's vars block already sets EMAIL_PROVIDER, STORAGE_PROVIDER, AWS_REGION, and AWS_S3_BUCKET — edit that file directly for anything that isn't a secret. AWS_S3_PUBLIC_URL is needed at build time (next.config.ts), so put it in apps/web/.env.production.local along with NEXT_PUBLIC_* (gitignored; it outranks .env.local). Also set EMAIL_FROM, Stripe price_… IDs, and BETTER_AUTH_TRUSTED_ORIGINS the same way Vercel's page lists them.

6. Build and deploy

pnpm --filter app run deploy      # cf:build (turbo build for @repo/database, then opennextjs-cloudflare build) + opennextjs-cloudflare deploy
pnpm --filter app run preview     # same build, served locally on real workerd (localhost:8787)

preview is the only local check that catches workerd-only failures — next dev and typecheck both pass on code that breaks in production.

Then in the Cloudflare dashboard: Workers → your worker → Settings → Domains & Routes to attach yourdomain.com (one click if the zone is already on Cloudflare). Or add a routes block to wrangler.jsonc after setup — restating vars / services / r2_buckets on that env, because named envs do not inherit them. Update NEXT_PUBLIC_APP_URL, BETTER_AUTH_URL, and BETTER_AUTH_TRUSTED_ORIGINS to the final domain and rebuild.

7. Shared post-deploy + verify

Follow the shared post-deploy configuration (schema push, Stripe webhook, OAuth callbacks), then run the full verification checklist. Pay special attention to:

  • the Stripe webhook delivery status — @better-auth/stripe already prefers constructEventAsync over the synchronous constructEvent when the installed Stripe SDK supports it, which is what makes webhook verification work on workerd without any change here. Confirm a real checkout.session.completed delivery still returns 200
  • file uploads (storage provider change)
  • /api/health (SELECT 1 — proves the database answers, not which adapter ran)

What's already handled for you

None of this needs to be written — it's in the repo already, gated on env vars or a runtime check:

  • Prisma driver adapterpackages/database/index.ts detects workerd at runtime (navigator.userAgent === "Cloudflare-Workers", or the CF_BUILD build-time flag) and picks PrismaNeon. CF_BUILD=1 bakes that adapter into the Worker bundle; Vercel/Docker use PrismaPg.
  • Stripe HTTP clientpackages/auth/stripe-client.ts constructs the SDK with Stripe.createFetchHttpClient(), required because stripe is in serverExternalPackages and would otherwise fall back to a Node-only HTTP client that fails on workerd
  • Middleware, not proxy.tsapps/web/middleware.ts deliberately isn't Next 16's proxy.ts convention, which is Node-runtime-only and can't run on Workers — see Sessions & Route Protection
  • Build output modenext.config.ts skips Next's standalone output under CF_BUILD/VERCEL, since OpenNext and Vercel both do their own bundling

Known limitations

  • Workers Paid plan required ($5/mo) — the bundle is roughly 7 MiB gzipped; the free tier's 3 MiB cap won't fit it
  • Neon WebSocket pool — Prisma's Neon adapter can't persist a connection pool across Worker isolates; expect occasional "Cannot perform I/O on behalf of a different request" errors under load. Hyperdrive (free on the paid plan) fixes this with warm pooled connections
  • next/image optimization is not available by default — configure Cloudflare Images in open-next.config.ts, or set images to unoptimized
  • Bundle size: the Prisma client is the heaviest dependency — if you're near the cap, confirm only the driver-adapter client is bundled
  • Logs: wrangler tail saasprokit-web --format pretty replaces the Vercel function log view

On this page