Deployment
Ship the same source to Bun, Cloudflare Workers, Vercel, or Netlify (all Node serverless except Bun and Workers).
backlex runs on four targets from the same source. Pick one based on the constraints you need.
| Bun (self-host) | Cloudflare Workers | Vercel Functions (Node 22, Build Output API) | Netlify Functions (Node 22) | |
|---|---|---|---|---|
| Database | SQLite or PG | D1 or Hyperdrive→PG | PG via DATABASE_DRIVER=neon-http (recommended — HTTP avoids cold-start TCP handshake) | PG via DATABASE_DRIVER=neon-http (recommended) |
| Storage | local fs / S3 / Bun.S3Client | R2 (S3 fallback) | S3 (aws4fetch) required — Lambda zip has no local fs | S3 (aws4fetch) required — Lambda zip has no local fs |
| Realtime | in-proc + SSE | Durable Objects + WS | loads but impractical (Lambda is stateless, SSE capped by function execution limit) | loads but impractical (same Lambda caveat) |
| SAML | yes | yes (nodejs_compat) | yes (Node 22 native crypto) | yes (Node 22 native crypto) |
| LDAP / SMTP | yes | 503 (no raw TCP) | yes (Node 22 has raw TCP) | yes (Node 22 has raw TCP) |
| Sandbox | Bun worker | QuickJS / remote HTTP | QuickJS / remote HTTP | QuickJS / remote HTTP |
| Image | Bun.Image | CF Image Resize | passthrough | passthrough |
| Cron | setInterval | wrangler triggers | .vercel/output/config.json crons (emitted by scripts/build-vercel-output.ts; Vercel sends Authorization: Bearer $CRON_SECRET automatically) | scheduled function pings /api/_cron/tick with x-cron-secret: $CRON_SECRET |
| Cost | VPS | $0–5/mo | $0–20/mo | $0–19/mo |
Bun (self-host)
APP_URL=https://your.app \DATABASE_URL=postgres://user:pass@host:5432/backlex \AUTH_SECRET=$(openssl rand -hex 32) \bun run --cwd apps/web dev:bunFor a managed process: systemd unit, Docker, or pm2. The Bun scheduler
boots inside apps/web/src/server/entries/bun.ts; cron functions tick
every 30 seconds.
Cloudflare Workers
apps/web/wrangler.toml covers the bindings. First-time setup:
cd apps/web
wrangler d1 create backlex # paste id into wrangler.tomlwrangler r2 bucket create backlex-fileswrangler vectorize create backlex-embeddings --dimensions=1536 --metric=cosine
wrangler secret put AUTH_SECRETwrangler secret put RESEND_API_KEY # optional — email provider key # (or SENDGRID_API_KEY / MAILGUN_API_KEY / SES_*)wrangler secret put OAUTH_GOOGLE_CLIENT_ID # optional# ...# EMAIL_FROM (and EMAIL_PROVIDER) aren't secrets — put them in wrangler.toml [vars].# smtp is not available on Workers; use an HTTP provider here.
wrangler d1 migrations apply backlex --remotewrangler deployGit integration (recommended)
Connect the GitHub repo from the Cloudflare dashboard and let every push to
main auto-deploy. No GitHub Actions workflow is needed.
- dash.cloudflare.com → Workers & Pages → workeros-api → Settings → Builds → Connect → pick the backlex repo.
- Production branch:
main. - Build command:
bun run db:migrate:d1:remote && bun run build(the migration runs first so deploy never fronts a schema mismatch; the build container’swrangleris auto-authenticated, no token needed). - Deploy command:
cd apps/web && bunx wrangler deploy— thecdis mandatory. wrangler 4 detects Bun workspaces and refuses to run from repo root with"application detection logic has been run in the root of a workspace".apps/web/is wherewrangler.tomllives and where the Vite build emitsdist/backlex_api/+dist/client/. - Root directory: leave at repo root (
/). - Build environment variables — the build container needs the same
wrangler.tomlbindings as production. Secrets stay on the Worker (wrangler secret put …); only build-time vars belong here. - Deploy. Every push to
mainships to Production; non-production branches get preview URLs automatically.
PR test gating still runs in GitHub Actions (.github/workflows/test.yml)
— lint + typecheck + bun test + bun run build:targets exercise all four
runtimes (Bun / CF / Vercel / Netlify) on every PR and push to main.
remote-http sandbox (optional, DB-aware functions on edge)
QuickJS-WASM runs functions in-isolate everywhere but is sync-only with no
ctx.* host I/O. For DB/fetch/email-aware functions on an edge runtime, run
the out-of-isolate executor (apps/web/templates/fn-exec-server) somewhere
eval / new Function are allowed — Fly.io, Railway, Render, a plain VM,
Cloudflare Containers:
bun run apps/web/templates/fn-exec-server/index.ts # listens on :8790Then point the Worker at it:
wrangler secret put FUNCTIONS_EXEC_URL # https://your-exec-host (base URL, no /run)wrangler secret put SANDBOX_RPC_TOKEN # generate with `openssl rand -hex 32` (same on both)wrangler secret put SELF_URL # https://api.your.appwrangler deployThe selector falls back to QuickJS when FUNCTIONS_EXEC_URL is unset, so
Workers users still get a sandbox — sync only.
Worker template artifact
.github/workflows/publish-worker-template.yml packages the same
apps/web CF build that ships to workeros-api.kinyasfurkan.workers.dev
into a downloadable tarball, so a downstream provisioner (the private
backlex-cloud repo) can fetch a pre-built bundle and wrangler deploy
it into a fresh customer account — D1 + R2 + Worker in one shot — without
re-cloning + re-building this repo.
Triggers
- Push a tag of the form
worker-v<semver>(e.g.worker-v0.1.0) → strict lint + typecheck +bun test+ build, then the tarball is attached to a GitHub Release named after the tag. Downstream consumers pin to a specific tag. workflow_dispatch(with requiredversioninput) → same gates, but the tarball is uploaded as a workflow artifact (worker-template-dryrun, 14-day retention) instead of a Release asset. Use this to confirm the bundle assembles before cutting an intentionalworker-v*tag.
Tarball contents (backlex-app-worker-v<X.Y.Z>.tar.gz):
backlex-app-worker-v<X.Y.Z>/├── worker/│ ├── index.js # bundled Hono worker entry (ES module)│ └── assets/** # vendor chunks + per-migration SQL chunks├── client/ # SPA static assets (apps/web/dist/client/**)├── migrations/│ ├── sqlite/*.sql # one file per packages/db/drizzle/sqlite/* migration│ └── manifest.json # ordered list + sha256 + bytes per migration├── wrangler.template.toml # bindings declaration with placeholders└── meta.json # version, gitSha, builtAt, bun/node versionswrangler.template.toml carries four placeholders the provisioner
substitutes per customer before wrangler deploy:
__D1_DATABASE_ID__—idof the newly-created D1 database.__R2_BUCKET_NAME__— name of the newly-created R2 bucket.__APP_URL__— customer-facing Worker URL.__R2_PUBLIC_BASE__—pub-*.r2.devorigin (or custom domain).
AUTH_SECRET, OPENAI_API_KEY, RESEND_API_KEY, etc. stay as Worker
secrets (wrangler secret put …) — they are never inlined in the
template. The .dev.vars* files and any maintainer-account IDs from
apps/web/wrangler.toml are stripped during assembly; the workflow
also never reads any GitHub secret beyond the auto-injected
GITHUB_TOKEN (so a fork can run it safely).
Local dry-run
bun run scripts/build-worker-template.ts --version 0.1.0# → ./dist-worker-template/backlex-app-worker-v0.1.0.tar.gz
# Skip the SPA build if you've just run `bun run build` yourself:bun run scripts/build-worker-template.ts --version 0.1.0 --no-buildVercel
vercel.ts at the repo root configures the install/build commands;
everything else (routing, function registration, crons) is emitted by
scripts/build-vercel-output.ts into .vercel/output/ using the
Vercel Build Output API. The pipeline ships:
- A single Node serverless function under Fluid Compute that handles
every
/api/*path (pre-bundled by Bun, runtimenodejs22.x, 60smaxDuration). - The admin SPA as static assets.
- A cron entry that pings
/api/_cron/tickonce per day at 00:00 UTC (Hobby plan only allows daily; editscripts/build-vercel-output.ts’sconfig.jsonblock + upgrade to Pro for finer intervals).
Why Build Output API (and not zero-config)?
Three things ruled out the simpler paths:
- Vercel’s function bundler doesn’t transpile
.tsworkspace packages. Ourpackages/{core,auth,db}export.tssource viapackage.json::exports; the bundler ships them as broken symlinks and Lambda module evaluation crashes at load. - Vercel’s Node runtime keys off the handler shape. A bare
export default async function (req)is read as the legacy Express signature(IncomingMessage, ServerResponse)— Hono can’t consume that. The Web Standardexport default { fetch(req): Response }shape opts into the modern path (Hono’sapp.fetchmatches it). - Zero-config function discovery runs before
buildCommand. Anyapi/*.mjsour build script writes is invisible to that scan, and declaring it viavercel.ts::functionsfails the deploy in the same pre-build step ("pattern doesn't match any Serverless Functions inside the api directory").
The Build Output API takes precedence over all three: when
.vercel/output/ exists after the build step, Vercel uses it verbatim
and skips its own discovery + validation entirely.
How the build works
The build command chains the Vite SPA build with the output script:
DEPLOY_TARGET=vercel bun run --cwd apps/web build && bun scripts/build-vercel-output.ts. The output script:
- Pre-bundles
apps/web/src/server/entries/vercel-fn-entry.tswithBun.build()into.vercel/output/functions/api/index.func/index.mjs. The bundle inlines every workspace and npm dep (because Vercel’s nft tracer can’t follow Bun’snode_modules/.bunmonorepo store) and aliasesbun:sqliteto its throwing shim (Node ESM can’t parse thebun:specifier). - Writes the matching
.vc-config.json(nodejs22.xruntime,fetchhandler, 60smaxDuration). - Copies
apps/web/dist/client/into.vercel/output/static/. - Writes
.vercel/output/config.json(v3 schema) with the routing — a rewrite that funnels every/api/(.*)request into the single function as/api/index?__path=$1, then ahandle: "filesystem"pass, then an SPA fallback — and the cron entry.
The handler at vercel-fn-entry.ts reads __path from the rewritten
URL’s query string and rebuilds request.url so Hono routes the
original /api/auth/get-session etc. instead of the literal
/api/index.
Git integration (recommended)
Connect the GitHub repo from the Vercel dashboard and let every push to
main auto-deploy. No GitHub Actions workflow is needed.
- vercel.com → Add New → Project → pick the backlex repo.
- Framework Preset:
Other.vercel.tsoverrides install/build, and the Build Output API takes over from there — the preset only affects defaults that get overridden anyway. - Root Directory: leave at repo root (
/). Do not point it atapps/web; the build command already runs Vite inside the workspace. - Environment Variables — set these on Production (and ideally
Preview too). Minimum:
APP_URL—https://your-project.vercel.app(or the custom domain)AUTH_SECRET—openssl rand -hex 32DATABASE_URL— Postgres connection string (Neon recommended)DATABASE_DRIVER=neon-http— recommended on serverless Lambdas; HTTP avoids the TCP handshake cost per cold startS3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY(+ optionalS3_ENDPOINT,S3_REGION) — Lambda zip has no local fsCRON_SECRET—openssl rand -hex 32. Vercel automatically attachesAuthorization: Bearer $CRON_SECRETto cron requests; the route also acceptsx-cron-secretfor manual callers- Optional:
EMAIL_PROVIDER+EMAIL_FROM+provider creds,OAUTH_*_CLIENT_ID/SECRET,AUTH_PLUGINS, etc. — see the table below
- Deploy. Every push to
mainships to Production; every PR gets a Preview URL. The first request runs DB migrations againstDATABASE_URLautomatically.
CLI alternative
vercel linkvercel env add DATABASE_URLvercel env add DATABASE_DRIVER # neon-httpvercel env add AUTH_SECRETvercel deploy --prodDatabase driver on Vercel
Node 22 Lambdas expose node:net/node:tls, so plain postgres-js
works — but every cold start pays the TCP handshake. neon-http over
@neondatabase/serverless skips that and is the recommended path. Set
DATABASE_DRIVER=neon-http; the Vercel context honors it.
Vercel Postgres (legacy product name, now a Neon-managed integration
inside the Vercel dashboard) uses the same Neon driver — point
DATABASE_URL at it and the configuration is identical.
Storage on Vercel
Lambda zips have no writable filesystem; set the S3 env vars and the storage adapter switches to the S3 path automatically. Works with AWS S3, Cloudflare R2, Backblaze B2, MinIO, DigitalOcean Spaces, Wasabi — anything that speaks the S3 API.
S3_BUCKET=backlexS3_REGION=auto # `auto` for R2; AWS region for S3S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # blank for AWS S3S3_ACCESS_KEY_ID=…S3_SECRET_ACCESS_KEY=…Selection priority in buildContext:
R2binding (Cloudflare Workers) — fastest path on the edge.S3_BUCKETset —Bun.S3Clientwhen running on Bun, elseaws4fetch(works in any runtime with WHATWGfetch).- otherwise — local
fsStorage(Bun self-host dev only).
Pre-signed URLs are exposed via the signedUrl(key, ttlSeconds) adapter
method (e.g. for direct browser uploads / public CDN links).
Runtime caveats
Vercel Functions run on Node 22, so the Bun-self-host surface is
mostly available — SAML, LDAP, SMTP all load (full
node:crypto/node:net/node:tls). Two exceptions:
- Realtime SSE loads but is impractical: Lambda is stateless, so
the in-process pub/sub
Mapdoesn’t survive cold starts, and function execution time caps the SSE stream. Use Cloudflare Workers (Durable Object) or Bun self-host for realtime. bun:sqliteis aliased to a throwing shim. Always setDATABASE_DRIVER=neon-http(or any non-sqlite driver) so the sqlite code path never loads.
Image transforms on Workers
GET /api/storage/:key?width=…&format=… runs through Cloudflare Image
Resizing when:
env.R2_PUBLIC_BASEis set to a stable public origin for the bucket (r2.dev URL or a custom domain bound viawrangler r2 bucket domain add), AND- the file’s ACL is
public.
Enable the r2.dev origin once per bucket:
bunx wrangler r2 bucket dev-url enable backlex-filesbunx wrangler r2 bucket dev-url get backlex-files# → Public URL: https://pub-<hash>.r2.devThen set R2_PUBLIC_BASE = "https://pub-<hash>.r2.dev" under [vars]
in wrangler.toml and redeploy. Without it, transform requests on
Workers return 422 VALIDATION (no silent passthrough). Bun deployments
transform in-process via Bun.Image and don’t need this var.
Heads-up: enabling the r2.dev URL makes every object in that bucket
readable by anyone who knows the key path — see docs/storage.md
“Security tradeoffs” for the mitigations.
Netlify
netlify.toml at the repo root deploys the admin SPA + a Node 22
serverless function for /api/* + a scheduled function for cron.
- API function source:
apps/web/src/server/entries/netlify-fn-entry.ts(a thin Web Standard(req) => app.fetch(req)shim). - Scheduled function source:
apps/web/netlify/functions/cron.ts. - Both use Netlify Functions v2 (Web Standard
export default). Mixing v1 (export const handler) with v2 in the samefunctions/directory makes the runtime fall back to v1 for the whole directory — keep new functions v2.
Why pre-bundle?
The API function is pre-bundled by scripts/build-netlify-fn.ts
during the build command, not by Netlify’s nft bundler. Two
incompatibilities forced this:
- Netlify’s bundler doesn’t transpile TypeScript, and our workspace
packages (
packages/{core,auth,db}) export.tssource via theirpackage.jsonexportsfield. The bundler ships them as symlinks to apackages/directory that isn’t in the function zip, so Lambda module evaluation crashes at load time. - Even after working around (1), Netlify’s nft tracer doesn’t follow
imports through Bun’s monorepo
node_modules/.bunstore, so npm deps likepostgres(imported transitively bydrizzle-orm/postgres-js) end up missing from the zip.
The pre-bundle uses Bun.build() with a resolve plugin that aliases
bun:sqlite to apps/web/src/server/shims/bun-sqlite-shim.ts (Bun
specifier the Node ESM loader can’t parse otherwise). The output is
a single self-contained apps/web/netlify/functions/api.mjs that
Netlify’s bundler just zips. The pre-bundled artifact is gitignored —
build runs it fresh every deploy.
Git integration (recommended)
- app.netlify.com → Add new site → Import an existing project → pick the backlex repo.
- Base directory: leave empty (repo root). The
netlify.tomlalready setsbase = "". - Build command, Publish directory: leave empty too —
netlify.tomloverrides both. The build command chains the Vite build with the pre-bundle script:DEPLOY_TARGET=netlify bun install --frozen-lockfile && DEPLOY_TARGET=netlify bun run --cwd apps/web build && bun scripts/build-netlify-fn.ts. - Bun runtime:
BUN_VERSIONis pinned to1.3.14in[build.environment]. Override per-site if you need a newer version. - Environment Variables (Site configuration → Environment variables):
APP_URL—https://your-site.netlify.app(or custom domain)AUTH_SECRET,DATABASE_URL,DATABASE_DRIVER=neon-http,S3_BUCKET+S3_ACCESS_KEY_ID+S3_SECRET_ACCESS_KEY(+ optionalS3_ENDPOINT,S3_REGION) — same as VercelCRON_SECRET— the scheduled function reads this and attachesx-cron-secretwhen pinging/api/_cron/tick. Without it the cron function 500s loudly instead of silently dropping ticks- Optional providers — same as Vercel
- Deploy. Every push to
mainships to Production; every PR gets a Deploy Preview.
CLI alternative
The Netlify CLI’s monorepo prompt asks which workspace you’re targeting;
pass --filter @backlex/web to keep it non-interactive:
netlify env:set DATABASE_URL postgres://... --filter @backlex/webnetlify env:set AUTH_SECRET $(openssl rand -hex 32) --filter @backlex/webnetlify deploy --build --prod --filter @backlex/webFor a brand-new site, netlify api createSiteInTeam plus a manual
“Link site to Git” in the dashboard avoids the interactive flow
entirely (you need the GitHub OAuth + deploy key only Netlify’s
dashboard can provision).
Runtime caveats
Netlify Functions run on Node 22, so the Bun-self-host surface is
mostly available — SAML, LDAP, SMTP, samlify all load (full
node:crypto/node:net/node:tls). Two exceptions:
- Realtime SSE loads but is impractical: Lambda is stateless, so
the in-process pub/sub
Mapdoesn’t survive cold starts, and function execution time caps the SSE stream. Use Cloudflare Workers (Durable Object) or Bun self-host for realtime. bun:sqliteis aliased to a throwing shim. Always setDATABASE_DRIVER=neon-http(or any non-sqlite driver) so the sqlite code path never loads.
Environment variables (all targets)
| Var | Required? | Notes |
|---|---|---|
APP_URL | yes | Admin UI origin (CORS + auth callbacks) |
AUTH_SECRET | yes | 32-byte random; signs sessions |
DATABASE_URL | yes¹ | Postgres URL (¹ unless on Workers with D1). Supabase, Neon, Xata, Vercel PG, self-host all supported — see docs/database-providers.md for the matrix |
DATABASE_DRIVER | no | postgres-js (default) or neon-http (required on Vercel Edge) |
EMAIL_PROVIDER + EMAIL_FROM | no | Email transport: console/resend/sendgrid/mailgun/ses/smtp (auto-detected from creds if EMAIL_PROVIDER unset; smtp not on Workers) |
RESEND_API_KEY | SENDGRID_API_KEY | MAILGUN_API_KEY+MAILGUN_DOMAIN | SES_REGION+SES_ACCESS_KEY_ID+SES_SECRET_ACCESS_KEY | SMTP_HOST+SMTP_PORT+SMTP_USER+SMTP_PASSWORD | no | Credentials for the chosen email provider |
OAUTH_{GOOGLE,GITHUB,APPLE}_CLIENT_{ID,SECRET} | no | enable each provider when both set; Apple’s _CLIENT_ID is the Service ID, _CLIENT_SECRET is the signed JWT |
AUTH_PLUGINS | no | Comma-separated: passkey,magic-link,email-otp,anonymous |
FUNCTIONS_FETCH_ALLOW | no | Comma-separated host allow-list for ctx.fetch |
FUNCTIONS_EXEC_URL | no | Base URL of a remote-http function executor |
SANDBOX_RPC_TOKEN | no | remote-http only — shared secret for ctx.* RPC |
SELF_URL | no | Required for cron-triggered remote-http RPC |
S3_BUCKET + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY | no¹ | ¹ Required on Vercel / Netlify Functions (no local fs in a Lambda zip); optional on Bun (defaults to fsStorage) and Workers (R2 binding preferred) |
S3_ENDPOINT | no | Custom S3 endpoint for R2/B2/MinIO/Spaces |
S3_REGION | no | Defaults to auto |
R2_PUBLIC_BASE | no | Workers only. Public origin for the R2 bucket; activates cf.image edge resizing for public-ACL files. See docs/storage.md. |
Verifying a deploy
curl https://your.app/health# { "ok": true, "dialect": "pg" | "sqlite", "ts": 1730000000000 }Then sign up at https://your.app/sign-up (or your admin URL); the first
user gets the admin role.