Skip to Content
Documentation

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

FeatureOn WorkersWhy
The site, SSR, prerendered pages, /api/*āœ… WorksOne Worker answers every route; prerendered pages are served as static assets.
WebSocketsāœ… WorksThe realtime hub owns the sockets. See Cloudflare WebSockets.
Postgresāœ… WorksThrough scopedDatabase, one client per request, or Hyperdrive.
S3 / R2, Supabase storageāœ… WorksUploads go straight to the bucket.
Local file storageāŒ No diskA Worker has no persistent file system. Use a bucket.
Image re-encoding (storage.image)āŒ UnsupportedIt needs sharp, a native module Workers cannot load. Leave storage.image out of the Worker build.
Redisāž– Not usedIts long-lived connection does not fit a Worker. Cache and rate limits fall back to each isolate's memory.
In-process cronāŒ UnsupportedNo process stays up. Call the cron endpoint from a scheduler instead.
Migrationsāž– From NodeRun db:migrate from your machine or CI against the production database.

Before you begin

  • A Single App, the one layout create-vitnode-app offers 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:
Install Wrangler
bun add -d wrangler

A 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:

vite.config.ts
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:

wrangler.jsonc
{
  "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:

src/vitnode.api.config.ts
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:

.dev.vars
POSTGRES_URL=postgresql://user:password@127.0.0.1:5432/vitnode
VITNODE_WEB_URL=http://localhost:8787
CRON_SECRET=a-long-random-string

Run the app's build script, then start the Worker in workerd, the runtime Cloudflare uses:

npx wrangler dev

The 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_SECRET

Then 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:

Migrate
POSTGRES_URL="postgresql://…" bun run db:migrate

Run 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 deploy

An 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:

wrangler.jsonc (excerpt)
"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:

src/vitnode.api.config.ts (excerpt)
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 dev

Pass 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:

wrangler.jsonc (excerpt)
"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

SymptomCause and fix
The Worker ignores .dev.vars, and the bindings table lists none of its variablesWrangler 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 browserThe 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 databaseThe client was created once at module load. Use scopedDatabase.
TLS_HANDSHAKE_FAILED from the database, and /api/health/ready answers 503The 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 workQueries 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 CPUThe 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 largeShrink the Worker, and check that you are on Workers Paid.