Deployment
workeros runs on four targets from the same source. Pick one based on the constraints you need.
| Bun (self-host) | Cloudflare Workers | Vercel Edge | Netlify Edge | |
|---|---|---|---|---|
| Database | SQLite or PG | D1 or Hyperdrive→PG | PG via DATABASE_DRIVER=neon-http (required) | PG (postgres-js works under Deno; neon-http recommended) |
| Storage | local fs / S3 / Bun.S3Client | R2 (S3 fallback) | S3 (aws4fetch) required — boot fails without it | S3 (aws4fetch) required — boot fails without it |
| Realtime | in-proc + SSE | Durable Objects + WS | 503 (not supported) | 503 (not supported) |
| SAML | yes | yes (nodejs_compat) | 503 (xml-crypto unavailable) | 503 (xml-crypto unavailable) |
| LDAP / SMTP | yes | 503 (no raw TCP) | 503 (no raw TCP) | 503 (no 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.json crons (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/workeros \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 workeros # paste id into wrangler.tomlwrangler r2 bucket create workeros-fileswrangler vectorize create workeros-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 workeros --remotewrangler deployremote-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.
Vercel
vercel.json at the repo root deploys both admin (static SPA from
apps/web/dist/client) and API (api/index.ts — a tiny shim that
re-exports apps/web/src/server/entries/vercel.ts as an Edge Function).
Cron triggers ping /api/_cron/tick once per minute.
Git integration (recommended)
The typical setup is to 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 workeros repo.
- Framework Preset:
Other. Thevercel.jsonin the repo root overrides install/build/output, so the preset only affects defaults. - 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— Neon HTTP connection stringDATABASE_DRIVER=neon-http— forced on by the Vercel entry, but declare it for clarityS3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY(+ optionalS3_ENDPOINT,S3_REGION) — edge boot fails without storageCRON_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_URL # Neon HTTP recommended for Edgevercel env add AUTH_SECRETvercel deploy --prodDatabase driver on Edge
Vercel’s edge runtime doesn’t expose node:net, so plain postgres-js
won’t connect. Two options:
-
Neon HTTP —
@neondatabase/serverlessworks over HTTP.Terminal window bun add @neondatabase/serverless --workspace=@workeros/dbThen swap
createPgClientto use Neon’s pooler. (Drizzle hasdrizzle-orm/neon-httpadapter.) workeros doesn’t ship this swap by default — apply locally if you target Vercel. -
Vercel Postgres — same Neon driver, managed inside Vercel.
Storage on Edge
Edge functions can’t write to disk; 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=workerosS3_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).
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 workeros-filesbunx wrangler r2 bucket dev-url get workeros-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 mirrors Vercel — admin SPA + edge
function for /api/* + scheduled function for cron. Edge function source
lives in apps/web/netlify/edge-functions/entry.ts, scheduled function in
apps/web/netlify/functions/cron.ts.
Git integration (recommended)
- app.netlify.com → Add new site → Import an existing project → pick the workeros repo.
- Base directory: leave empty (repo root). The
netlify.tomlalready setsbase = "". - Build command, Publish directory: leave empty too —
netlify.tomloverrides both (command = "DEPLOY_TARGET=netlify bun install --frozen-lockfile && DEPLOY_TARGET=netlify bun run --cwd apps/web build",publish = "apps/web/dist/client"). - 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
netlify initnetlify env:set DATABASE_URL postgres://...netlify env:set AUTH_SECRET $(openssl rand -hex 32)netlify deploy --prodThe same edge-runtime caveats apply: Postgres needs an HTTP-friendly driver; storage needs S3.
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) |
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 edge (no fs); 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.