The app runs on Bun by default and on Cloudflare Workers as a selectable second target. Both run the same modules, routes, schema and tests. Nothing in a module knows which host it is on.
apps/api/src/platform/ defines the narrow contract the app needs from its
host. Module code reaches it through getPlatform() and never touches
Bun.*, node:fs, setInterval or a database driver directly.
| Seam | Bun | Cloudflare Workers |
|---|---|---|
env |
Bun.env |
Worker bindings (vars + secrets) |
kv |
in-process Map per namespace, TTL swept by one unref()'d timer |
same, inside the Durable Object |
scheduler |
setTimeout + setInterval, non-overlapping |
Durable Object alarm, multiplexed onto one slot |
openDatabase |
libsql file (db/bun.ts) |
Durable Object SQLite (workers/db.ts) |
capabilities |
everything | see the table below |
The adapter is installed once at startup: src/index.ts on Bun,
src/workers/entry.ts on Workers. Tests can install a fake with
setPlatform().
A runtime declares what it can do; the app refuses configuration it cannot honour rather than failing later at request time.
| Capability | Bun | Workers | Enforced at |
|---|---|---|---|
encryptionAtRest |
yes | no | boot — DB_ENCRYPTION=true is rejected |
argon2 |
yes | no | boot — use a pbkdf2-sha256 hash |
pbkdf2MaxIterations |
unbounded | 100,000 | boot — a hash above the ceiling is refused |
subprocess |
yes | no | the cron shell action throws |
filesystem |
yes | no | static assets, file logs and the OIDC discovery cache fall back |
residentTimers |
yes | no | boot — CRON_ENABLED=true is rejected |
Workers-specific consequences:
- Database encryption is the host's job. Durable Object storage is
encrypted by Cloudflare; libsql's page-level encryption has no counterpart
and the
meta.db/ DEK rotation machinery is Bun-only. - Passwords must be
pbkdf2-sha256, generated with at most 100,000 iterations.Bun.password's argon2 and bcrypt verification has no WebCrypto equivalent, and Cloudflare refuses PBKDF2 above that ceiling — a hash minted at the OWASP baseline of 600,000 throws rather than verifying slowly. Note thatwrangler devcomputes any iteration count, so a hash that authenticates locally can still fail in production; the boot guard catches it either way.hashPasswordclamps to the running platform's ceiling, and a hash for another runtime can be minted with an explicit count. - Logs go to the console as JSON, which is what
wrangler tailreads. - The SPA is served by Cloudflare's asset pipeline, not by the app's static middleware.
- Scheduled jobs do not run in-process. See below.
- A configuration change resets the object. A Durable Object keeps the
envit was constructed with for as long as it stays resident, and this app schedules alarms, so it can stay resident indefinitely. Without interventionwrangler secret putwould appear to do nothing: the new value binds to the stateless Worker while the object serving every request still holds the old one. The Worker therefore sends a digest of its bindings with each request, and the object resets itself when that digest stops matching the one it booted with. The request that detects it is answered with 503; the next one boots against the current bindings. - Rate limits that matter survive eviction. The per-user creation cap is
stored in the
rate_limitstable precisely because an in-memory window could be reset by pacing requests around an eviction. The in-memory, IP-keyed limiters keep short windows only, where the reset is not worth exploiting. - In-flight OAuth logins are more fragile. The PKCE verifier is sealed
with a key held in memory and never persisted, so a login that spans an
eviction fails with
oauth_state_invalidand the user retries. On Bun the same thing happens across a restart; it is just rarer there.
D1 was the obvious first choice and is the wrong one for this codebase.
- D1 runs in auto-commit.
BEGIN/COMMITare not available through the Worker API, and its only atomic unit is a staticbatch(). This app opens an interactive transaction in 20+ places, several of which read before they write (policy tuple rewrites, backup restore, file reference counting). Those cannot be expressed as a batch without losing rollback. - Every D1 query is a network round trip. The policy engine walks relation tuples recursively, so a single authorized request can issue dozens of small queries — the pathological shape for a remote database.
A Durable Object owns its SQLite file locally: queries are in-process, and
storage.transaction() accepts an async callback and rolls back when it
rejects (verified in tests/workers/smoke.ts). The whole app lives in one
object named app, which keeps the same single-writer, single-memory-space
model the Bun process has — so the in-memory rate-limit buckets, step-up
tokens and per-request policy cache stay correct.
The trade-off is a single instance in a single location, with 10 GB of storage and a 2 MB row ceiling. Blob content goes to R2, so the row limit does not bind; throughput is bounded by one object's sequential execution.
Row pricing is identical to D1 (25 billion reads and 50 million writes included per month on the paid plan, then $0.001 per million rows read and $1.00 per million rows written). Stored data is cheaper than D1 — $0.20 per GB-month against $0.75, with the first 5 GB included. The difference is that Durable Objects also bill compute duration: 400,000 GB-s are included per month, and a 128 MB object held in memory for an entire 30-day month costs about 332,000 GB-s, so a continuously warm single-instance deployment fits inside the included allowance. Request billing is $0.15 per million after the first million.
Everything the flow needs is runtime configuration; nothing is stored in the settings table. A deployment needs:
| Setting | Notes |
|---|---|
OAUTH_ISSUER |
Discovery fills the authorize, token, userinfo and end-session endpoints |
OAUTH_CLIENT_ID |
|
OAUTH_CLIENT_SECRET |
Omit for a public client; PKCE then carries the proof |
OAUTH_PKCE |
Defaults to true |
APP_URL |
Required in production — the callback URL is never inferred from forwarded headers |
CORS_ORIGIN |
Required in production once OAuth is in play |
DEFAULT_ADMIN |
Identities that are always admins; an email entry needs the provider's email_verified |
Register ${APP_URL}${BASE_PATH}/api/account/auth/callback with the
provider. The authorize request asks for openid profile email, sends
response_type=code, and adds code_challenge with code_challenge_method=S256
when PKCE is on.
Single-user mode and OpenID Connect are mutually exclusive in practice: the
login page renders a local form or a provider button, never both, and
single-user wins. A deployment meant to use a provider must leave
SINGLE_USER_MODE unset, or the provider it exists for is hidden.
Two Workers-specific consequences:
- Discovery is fetched on every boot. The cache is a file, and there is
no filesystem here, so each Durable Object start re-reads the provider's
.well-known/openid-configuration. A provider that is unreachable at that moment leaves the endpoints unset and the login route answersoauth_not_configured. SettingOAUTH_AUTHORIZE_URL,OAUTH_TOKEN_URLandOAUTH_USERINFO_URLexplicitly removes the dependency — they take precedence over discovery. - A login in flight does not survive a reset. The PKCE verifier is sealed
with a key held only in memory, so an eviction or a configuration change
mid-login fails it with
oauth_state_invalidand the user retries.
The cron module delegates scheduling to cronbake, which arms a timer per job
and assumes the process stays resident. A Durable Object is evicted when it
goes idle and only an alarm wakes it, so those timers would stop without an
error and jobs would silently never run. CRON_ENABLED=true is therefore
rejected at boot on Workers — the residentTimers capability is what that
check reads.
The job catalog, history and routes all still work; only the in-process ticker is absent. Drive jobs from outside instead: point a Cloudflare Cron Trigger (or any external scheduler) at
POST /api/cron/jobs/:id/trigger
which executes a job without the in-process scheduler and records the run in the same history. The route is admin-only.
The app's own background sweeps — file GC and audit retention — are
unaffected: they go through the scheduler seam, which is backed by the
Durable Object alarm and survives eviction.
bun run --filter @app/web build # the asset pipeline serves apps/web/dist
cd apps/api
cp .dev.vars.example .dev.vars # local secrets, gitignored
bun run workers:dev # workerd via wrangler
bun run workers:build # bundle only, no deploy
bun run workers:deploy # wrangler deployConfiguration lives in apps/api/wrangler.toml: the Durable Object binding
and its SQLite migration tag, the R2 bucket for blobs, the asset directory,
and the non-secret vars. Real secrets go through wrangler secret put.
A secret outranks a var of the same name, and wrangler secret publishes a
version of its own — so setting one after a deploy leaves two versions and a
question of which serves. Prefer deploying configuration as vars in the
same version as the code, and keep secrets for values that must not be
readable from the dashboard.
The asset pipeline answers every path the Worker is not listed for in
run_worker_first, serving the SPA's index.html for unknown ones. A route
prefix left out of that list never reaches the app — it returns HTML with a
200 — which is why every API surface, including the raw API, lives under
/api/*.
Without a BASE_PATH the setup above is complete. With one (say /u) the
asset pipeline cannot help: the SPA is built with BASE_PATH=/u and its HTML
references /u/assets/…, but the files sit at the root of apps/web/dist,
and the pipeline has no way to strip a prefix. So the Worker runs first for
the SPA's own paths and maps them itself (src/workers/spa.ts): it strips the
prefix and asks the ASSETS binding, which still applies the single-page
fallback, and it forwards /u/api/* to the app. Three things have to agree:
- the SPA built with the same
BASE_PATH; BASE_PATH(andHOME_PAGE, see.env.example) invars;run_worker_first = ["/", "/u/*"]inwrangler.toml, replacing["/api/*"].
The deploy workflow does all three from the BASE_PATH and HOME_PAGE
repository variables, and its smoke run goes through the prefix. At / the
Worker answers what the Bun server does: a redirect to the app, or the SPA
shell when HOME_PAGE=root. bun tests/workers/smoke.ts --base-path /u [--home-page root] runs this under workerd against a prefix-aware build in
apps/web/dist-smoke.
Attaching a custom domain needs the zone on the same account; state it with
zone_name rather than relying on inference. Adding any route disables the
workers.dev URL unless workers_dev = true is explicit, and a newly attached
hostname routes before its certificate is issued — HTTP answers while HTTPS
fails the handshake for a few minutes.
Migrations are bundled into src/db/migrations.generated.ts because a Worker
has no filesystem to read drizzle/ from. bun run db:generate regenerates
both; bun run db:check-migrations fails when the committed bundle is stale,
and CI runs it.
bun run test:workers boots the Worker in workerd and exercises boot,
login, transaction commit, transaction rollback and an R2 round trip. Pass
--url https://… to run the same suite against a deployment; the workers
workflow does that against a preview version when the repository has
Cloudflare credentials.