Deploying
Running `ratchet serve`, and deploying to a VPS, Vercel, or Cloudflare Workers.
ratchet serve runs on Bun — that's the CLI's own runtime, for local development. The bundle from ratchet build targets plain Node instead (dist/server.js, built with a small Node http-to-Fetch bridge, ratchet/router's serveNode), so a VPS/container host doesn't need Bun installed at all.
Every entry point — ratchet serve, that bundle, a Cloudflare Worker, a Vercel function — assembles the exact same server by calling createRatchetApp (@egig/ratchet/server). You hand it a db, an optional storage adapter, an optional console asset source, and the generated bundle; it mounts /api/auth, /api/automation, /api, /_site-assets, the console, and (when you opt in) the web app — in the one order that makes the linear route match come out right. It does no config loading and no dynamic import of generated files, so it runs unchanged inside a Worker's fetch(request, env) handler.
import { createRatchetApp } from '@egig/ratchet/server';
import { bundle } from './.ratchet/app.js'; // written by `ratchet generate`
const app = await createRatchetApp({
db, // required — a drizzle-postgres client you construct
bundle, // required — { models, domains, web? }
storage, // optional — omit and `field.file` writes 500
consoleAssets, // optional — omit and the console isn't mounted
consolePath: '/console', // default; validated, must not collide with /api
// web: { entrySrc, publicDir, generatedDir }, // only to serve the src/web/ site
});createRatchetApp returns an App; the caller listens (Bun.serve, serveNode, or export default). Nothing in ratchet build/ratchet generate/ratchet migrate targets a deploy runtime — those stay Bun-only dev tooling. The deploy entry file for Vercel/Cloudflare is a thin file you own: it constructs the db and storage that fit the target and calls createRatchetApp.
Local development
PORT=3000 DATABASE_URL=postgres://... ratchet serveNothing else to configure — see CLI Reference.
VPS / container
ratchet build bundles a Node server entry to dist/server.js (postgres.js + serveNode, console assets read straight off disk — a trivial file that just calls createRatchetApp). Ship that file, .ratchet/, and a DATABASE_URL:
ratchet build
DATABASE_URL=postgres://... PORT=3000 node dist/server.jsSame code path as ratchet serve, just without Bun/dev tooling at runtime — this bundle runs on plain Node.
Vercel
Vercel Functions default to a Node.js runtime — the same postgres.js driver as above would work unmodified. But every invocation is still a short-lived serverless instance, and a plain postgres() client per invocation can exhaust your database's max_connections once there's real concurrent traffic. The example entry uses Neon's HTTP driver instead (drizzle-orm/neon-http + @neondatabase/serverless), which needs no persistent connection — swap in your own provider's pooled connection string if you're not on Neon and it already pools for you.
// api/index.ts
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { createRatchetApp } from '@egig/ratchet/server';
import { bundle } from '../.ratchet/app.js';
const db = drizzle(neon(process.env.DATABASE_URL!));
// no consoleAssets, no web → an API-only function (`/api/auth`, `/api/automation`, `/api`,
// `/_site-assets`). App's own `.fetch` matches Vercel's fetch-export convention directly.
export default await createRatchetApp({ db, bundle });// vercel.json — funnels every /api/* request to the one function above
{ "rewrites": [{ "source": "/api/:path*", "destination": "/api" }] }The console isn't mounted here — omitting consoleAssets is a deliberate, supported choice. Copy .ratchet/console's built output into public/console (or wherever consolePath points) as part of your build step and let Vercel's CDN serve it directly — no function invocation, and no ConsoleAssetSource for Vercel to write.
For file fields, add a storage — a Vercel function is a regular Node process, so buildStorageAdapter (@egig/ratchet/storage) resolves credentials from env vars at cold start the same way serve does:
import { buildStorageAdapter } from '@egig/ratchet/storage';
const storage = await buildStorageAdapter(
{ driver: 's3', bucket: process.env.S3_BUCKET!, region: process.env.S3_REGION,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY! } },
'',
);
export default await createRatchetApp({ db, bundle, storage });Cloudflare Workers
Three pieces Workers don't give you for free: a TCP path to Postgres, a filesystem for the console UI's built assets, and file storage. All three bindings only exist inside the fetch handler, so build the App per request — createRatchetApp does no I/O, it's just route-table construction, and Hyperdrive already wants a fresh client per request.
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { FileStorage } from '@flystorage/file-storage';
import { createRatchetApp } from '@egig/ratchet/server';
import { bundle } from '../.ratchet/app.js';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = drizzle(postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false }));
const app = await createRatchetApp({
db,
bundle,
storage: new FileStorage(new R2StorageAdapter(env.FILES)), // see below
consoleAssets: createAssetsBindingSource(env.ASSETS), // see below
consolePath: '/console', // match ratchet.config.ts
});
return app.fetch(request);
},
};Database — Hyperdrive plus postgres.js, Cloudflare's documented pattern: Hyperdrive pools the upstream connection, so a fresh client per request is cheap and expected. Requires compatibility_flags: ["nodejs_compat"] and a hyperdrive binding in wrangler.jsonc. fetch_types: false skips a pg_catalog round-trip ratchet's generated schema doesn't need.
Console assets — Workers Static Assets serves .ratchet/console straight from Cloudflare's CDN. The console router's asset source is a small interface (getManifest(), getAsset(path)), so adapting the env.ASSETS binding to it is a few lines:
function createAssetsBindingSource(assets: { fetch(r: Request): Promise<Response> }): ConsoleAssetSource {
return {
async getManifest() {
const res = await assets.fetch(new Request('https://assets.local/manifest.json'));
return res.ok ? await res.json() : null;
},
async getAsset(assetPath) {
const res = await assets.fetch(new Request(`https://assets.local/assets/${assetPath}`));
if (!res.ok) return null;
return { body: await res.arrayBuffer(), contentType: res.headers.get('content-type') ?? 'application/octet-stream' };
},
};
}File storage — env.FILES is a native R2 binding. Implement flystorage's StorageAdapter directly against it (only write/read/deleteFile are ever called by the generic API router; stub the rest to throw) and wrap it in a FileStorage. Reusing @egig/ratchet/storage/s3 against R2's S3 API instead would trade the free binding for an authenticated HTTP round trip — not worth it inside the Worker the binding is scoped to.
Agent chat (/api/automation) — the automation router pulls in the LangChain stack (createAgent + @langchain/anthropic/@langchain/openai). Its only Node-builtin dependency is node:async_hooks, which compatibility_flags: ["nodejs_compat"] already covers, so it deploys with the rest of the Worker — but it hasn't been load-tested on workerd. If you hit a runtime error inside a chat turn on Workers, run the console and its agents on a Node target (ratchet serve, a container, or a Vercel Node function) and keep the generic API on Workers.
Full worker + R2StorageAdapter + wrangler.jsonc in the example.