Skip to main content

Deploy on Cloudflare Workers

A CovaraApp is a Hono app, so it works directly as a Worker fetch handler.

// src/worker.ts
import { drizzle } from "drizzle-orm/d1";
import { createCovara, type CovaraApp } from "covara";
import { todos } from "./schema";

interface Env { DB: D1Database }

let app: CovaraApp | undefined;

export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
app ??= createCovara({ cors: true }).resource(todos, {
db: drizzle(env.DB), id: todos.id, auth: { public: true },
});
return app.fetch(request, env, ctx);
},
};

If your app needs nothing from env, export default app works too.

D1 and transactions

Cloudflare D1 has no interactive transactions (drizzle's db.transaction() would issue BEGIN/COMMIT, which D1 rejects). Covara detects D1 automatically and adapts: single-statement mutations (create/update/replace/delete) auto-commit atomically, and batch upsert uses D1's atomic db.batch(). Two limitations are inherent to D1: nestedWrites creates run sequentially (not atomic), and a throwing onAfter* hook can't roll back an already-committed write. If you wire a custom driver the detection can't classify, set transactions: true | false in the resource config. See the mutation-tracking contract.

wrangler.toml

nodejs_compat is required (Covara uses node:crypto):

name = "my-app"
main = "src/worker.ts"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID"
wrangler d1 create my-app-db # copy database_id into wrangler.toml
npx drizzle-kit generate
wrangler d1 migrations apply my-app-db --local
wrangler d1 migrations apply my-app-db --remote
wrangler deploy

PostgreSQL on Workers

Use postgres-js with a connection-string secret, and consider Hyperdrive for pooling:

import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";

const buildApp = (env: { DATABASE_URL: string }) => {
const client = postgres(env.DATABASE_URL, { max: 5, fetch_types: false });
return createCovara({ cors: true }).resource(todos, { db: drizzle(client), id: todos.id });
};
wrangler secret put DATABASE_URL

See the full database matrix.

Shared state: Durable Object KV

The in-memory KV is per-isolate, and Cloudflare runs many isolates — without a shared KV, a mutation handled by one isolate never reaches SSE subscribers on another, and rate limits/sessions aren't shared. Use the Durable Object KV:

import { createCovara, createDurableObjectKV, setGlobalKV, type CovaraApp } from "covara";
export { CovaraKVDurableObject } from "covara";

interface Env { DB: D1Database; COVARA_KV: DurableObjectNamespace }

let app: CovaraApp | undefined;
const buildApp = (env: Env): CovaraApp => {
// Safe to memoize: createDurableObjectKV derives a fresh DO stub per operation
// (Workers requires request-scoped I/O), and subscription fan-out is set up
// lazily per live SSE stream — no eager cross-request socket.
setGlobalKV(createDurableObjectKV(env.COVARA_KV));
return createCovara().resource(/* ... */);
};

export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
app ??= buildApp(env);
return app.fetch(request, env, ctx);
},
};
[durable_objects]
bindings = [{ name = "COVARA_KV", class_name = "CovaraKVDurableObject" }]

[[migrations]]
tag = "v1"
new_sqlite_classes = ["CovaraKVDurableObject"]

Projects from npx covara create --template cloudflare wire all of this up. See Durable Object KV for how it works.

Scaling across instances

Run as many instances as you like behind a load balancer. For realtime to stay correct, give every instance a shared distributed KV (Redis on Node, the Durable Object KV on Workers) and initialize it via initializeKV so subscription fan-out is wired up:

import { initializeKV } from "covara/kv";
await initializeKV({ type: "redis", redis: { url: env.REDIS_URL } });

initializeKV sets the global KV so a mutation on one instance reaches subscribers on another, and rate limits, sessions, and the task queue are shared. Cross-process subscription fan-out is established lazily and per SSE stream — each live stream opens its own subscribe connection to the distributed KV and closes it when it ends, so a process doing only KV ops never opens one. The in-memory KV is per-process and must not be used when state spans instances.

Workers I/O safety

On Cloudflare Workers, request-scoped I/O objects must not be reused across requests. Covara handles this end-to-end: the Durable Object KV derives a fresh stub per operation, and each live SSE stream owns its own fan-out subscribe socket bound to its request context — so closing one stream never orphans another's, and a memoized app (app ??= buildApp(env)) is correct even under many concurrent subscriptions per isolate. initializeEventSubscription() still exists for back-compat but is no longer needed; fan-out self-establishes per subscription.

Cost: SSE subscriptions on Workers

Cloudflare bills CPU time, not wall-clock time. A long-lived idle SSE subscription costs essentially nothing while it waits — heartbeats and event pushes consume only microseconds of CPU each. Thousands of mostly-idle realtime connections are cheap to keep open.

Background tasks on the edge

There's no long-lived poller on Workers — use the Cloudflare Queues adapter, and drain the search outbox from a scheduled handler.

Runtime notes

  • Covara never reads process.env directly (readEnv/isProduction/isDebugEnabled), so it works where process doesn't exist.
  • Local filesystem storage is Node-only; use R2 or S3-compatible storage on Workers.