DeploymentsCloud
Cloudflare Workers
Deploy a VitNode Single App to Cloudflare Workers as one Worker - the site, the API and a Durable Object for WebSockets - with Wrangler, secrets, migrations and the size limit.
A VitNode Single App builds into one Cloudflare Worker: the site, the Hono API at /api/*, and a Durable Object, the realtime hub, that owns every WebSocket. Pages and the API run at the edge, and Postgres stays where it is, reached over TCP.
What works, and what does not
| Feature | On Workers | Why |
|---|---|---|
The site, SSR, prerendered pages, /api/* | ā Works | One Worker answers every route; prerendered pages are served as static assets. |
| WebSockets | ā Works | The realtime hub owns the sockets. See Cloudflare WebSockets. |
| Postgres | ā Works | Through scopedDatabase, one client per request, or Hyperdrive. |
| S3 / R2, Supabase storage | ā Works | Uploads go straight to the bucket. |
| Local file storage | ā No disk | A Worker has no persistent file system. Use a bucket. |
Image re-encoding (storage.image) | ā Unsupported | It needs sharp, a native module Workers cannot load. Leave storage.image out of the Worker build. |
| Redis | ā Not used | Its long-lived connection does not fit a Worker. Cache and rate limits fall back to each isolate's memory. |
| In-process cron | ā Unsupported | No process stays up. Call the cron endpoint from a scheduler instead. |
| Migrations | ā From Node | Run db:migrate from your machine or CI against the production database. |
Before you begin
- A Single App, the one layout
create-vitnode-appoffers for Workers. - Workers Paid. A VitNode Worker is above the 3 MiB limit of the Free plan; Shrink the Worker keeps it under the 10 MiB Paid limit.
- A PostgreSQL database that Cloudflare can reach over TCP. A Worker only trusts certificates from a public CA, so a provider such as Supabase needs Hyperdrive.
- Wrangler 4 in the app:
bun add -d wranglerA new app gets the first three steps below from the generator: answer Cloudflare Workers to "Where do you want to deploy?", or pass --deploy cloudflare. Follow them to convert an existing Single App.
Deploy it
Build for Workers
Build with the Workers preset and serve /api/ws from the realtime hub:
const config = defineConfig(({ command }) => ({
plugins: [
vitnode({
appRoot: import.meta.dirname,
nitro: command === 'build' ? { preset: 'cloudflare-module' } : {},
webSocket: { transport: 'cloudflare' },
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
}))vite dev stays on Node, so local development needs no Worker. A build with transport: "cloudflare" and another preset fails with an error that says so.
Configure Wrangler
Create wrangler.jsonc next to vite.config.ts. The build merges it into .output/server/wrangler.json and adds main and assets:
{
"name": "my-vitnode-app",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{ "name": "VITNODE_REALTIME", "class_name": "VitNodeRealtimeHub" },
],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["VitNodeRealtimeHub"] }],
"vars": { "VITNODE_INSTALLATION_ID": "default" },
}The binding name VITNODE_REALTIME is fixed. nodejs_compat provides AsyncLocalStorage, process.env and the Node APIs the Postgres driver uses. Cloudflare WebSockets explains the hub's settings.
Give each request its own database client
A Worker request cannot use a TCP connection another request opened, so a client created once at module load breaks on the second request. Wrap the client in scopedDatabase, and leave out redis:
import { scopedDatabase } from '@vitnode/core/cloudflare/database'
import { coreRelations } from '@vitnode/core/database/relations'
import { drizzle } from 'drizzle-orm/postgres-js'
export const vitNodeApiConfig = buildApiConfig({
dbProvider: scopedDatabase(() =>
drizzle({
connection: {
url: process.env.POSTGRES_URL,
max: 5,
max_pipeline: 1,
},
relations: coreRelations,
}),
),
// ...the rest of your config
})max_pipeline: 1 sends each connection's next query only after the previous one answers. Drizzle runs every query as an unnamed statement, and Hyperdrive never answers three or more of those queued on one connection, so a request that runs queries in parallel, such as the signed-in session, hangs without it. max: 5 keeps parallel queries parallel, on separate connections.
On Node (vite dev, db:migrate) scopedDatabase keeps one client per process, so the same config serves both. It closes a Drizzle client on postgres.js or node-postgres; pass scopedDatabase(create, { dispose }) for another driver.
Preview it locally
Put the variables for local runs in .dev.vars in the app's folder, and never commit it:
POSTGRES_URL=postgresql://user:password@127.0.0.1:5432/vitnode
VITNODE_WEB_URL=http://localhost:8787
CRON_SECRET=a-long-random-stringRun the app's build script, then start the Worker in workerd, the runtime Cloudflare uses:
npx wrangler devThe build leaves .wrangler/deploy/config.json, which points Wrangler at the generated config, so run every Wrangler command from the app's folder without --config. With --config .output/server/wrangler.json, Wrangler looks for .dev.vars in .output/server and starts without your variables, and without a warning.
Set the secrets and migrate
Store each production secret once:
npx wrangler secret put POSTGRES_URL
npx wrangler secret put VITNODE_WEB_URL
npx wrangler secret put CRON_SECRETThen apply migrations from your machine or CI, with POSTGRES_URL set to the production database. They read the compiled plugins, so run them after the build:
POSTGRES_URL="postgresql://ā¦" bun run db:migrateRun db:migrate before every deploy that changes the schema.
Deploy
Check the bundle and the bindings without uploading anything, then deploy:
npx wrangler deploy --dry-run --outdir .output/wrangler-dry-run
npx wrangler deployAn app made with --deploy cloudflare has these commands as its preview, check:cloudflare and deploy:cloudflare scripts.
Connect through Hyperdrive
A Worker opens its own TLS connection to POSTGRES_URL and only accepts a certificate signed by a public CA. Supabase signs its database certificates with its own CA, so every API request fails with TLS_HANDSHAKE_FAILED. Hyperdrive is Cloudflare's database proxy: it opens that connection for the Worker and pools it across requests.
Create a Hyperdrive config from your app's folder. For Supabase, use the Direct connection string, not a pooler one, because Hyperdrive does the pooling:
npx wrangler hyperdrive create vitnode --connection-string="postgresql://postgres:password@db.your-project.supabase.co:5432/postgres"Add the id it prints to wrangler.jsonc:
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "your-hyperdrive-id" }],scopedDatabase passes the Worker's bindings to its callback as env, and hyperdriveConnectionString(env) returns the binding's connection string, or undefined on Node and in a Worker without the binding. Fall back to POSTGRES_URL, so vite dev and db:migrate keep using it:
import {
hyperdriveConnectionString,
scopedDatabase,
} from '@vitnode/core/cloudflare/database'
dbProvider: scopedDatabase(({ env }) =>
drizzle({
connection: {
url: hyperdriveConnectionString(env) ?? process.env.POSTGRES_URL,
max: 5,
max_pipeline: 1,
},
relations: coreRelations,
}),
),For npx wrangler dev, give the binding a local database in your shell, so the credentials stay out of wrangler.jsonc:
export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:password@127.0.0.1:5432/vitnode"
npx wrangler devPass the binding's name as the second argument, hyperdriveConnectionString(env, 'DATABASE'), if you named it something else.
Shrink the Worker
By default Wrangler uploads every module the build wrote, including chunks nothing imports, without minifying them. A large app, with many plugins or compiled docs, can pass the 10 MiB Paid limit that way. Let Wrangler bundle from the entry and minify:
"no_bundle": false,
"minify": true,
"keep_names": false,
"upload_source_maps": false,The VitNode website went from 13.1 MiB to 6.6 MiB gzipped. Keep keep_names off: some server code sends functions to the browser as source text (the theme and scroll restoration scripts), and esbuild's name-keeping helper turns them into calls to a function the browser does not have, so every page the Worker renders fails with n is not defined. Turn upload_source_maps off too: a real deploy uploads the bundle's source maps unless told not to, and a large app's maps pass Cloudflare's 60 MiB limit (error 10021), which a dry run does not check.
If the app prerenders pages, also set "assets": { "html_handling": "drop-trailing-slash" }. A prerendered page is written as page/index.html, and Cloudflare otherwise redirects /page to /page/.
Check the result
With npx wrangler dev running, http://localhost:8787/api/health/ready answers 200 with {"status":"ok"}. If it answers 503, the Worker cannot reach POSTGRES_URL. wrangler deploy --dry-run prints the Worker's size as Total Upload: ⦠/ gzip: ā¦. To check WebSockets end to end, follow Cloudflare WebSockets.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The Worker ignores .dev.vars, and the bindings table lists none of its variables | Wrangler ran with --config .output/server/wrangler.json. Run wrangler dev from the app's folder without it. |
A Worker-rendered page fails with n is not defined in the browser | The Worker is minified with keep_names on. Set "keep_names": false. |
CONNECTION_CLOSED or Cannot perform I/O on behalf of a different request from the database | The client was created once at module load. Use scopedDatabase. |
TLS_HANDSHAKE_FAILED from the database, and /api/health/ready answers 503 | The database's certificate is not signed by a public CA, as with Supabase. Connect through Hyperdrive. |
| Image uploads fail with "The image optimization library (sharp) failed to load" | Remove storage.image from the Worker's API config. |
Deploy fails with total sourcemap size is too large (code 10021) | Set "upload_source_maps": false in wrangler.jsonc. |
Signed-in requests, such as /api/@vitnode/core/users/session, never answer while signed-out ones work | Queries pipelined through Hyperdrive. Set max_pipeline: 1 on the Postgres connection, see Give each request its own database client. |
Pages answer 503 and the logs show "Worker exceeded CPU time limit" after about 10 ms of CPU | The account is on Workers Free, which allows 10 ms of CPU per request. Upgrade to Workers Paid (30 s by default). |
| Deploy fails because the script is too large | Shrink the Worker, and check that you are on Workers Paid. |