DeploymentsCloud

Vercel

Deploy a VitNode app to Vercel: the settings that work, the features that need a server which stays up, and where migrations have to run instead.

A VitNode app builds with Nitro, and Nitro knows Vercel. Push the repository, point a project at it, and the site plus the Hono API mounted at /api/* come up as one serverless function - no adapter, no vercel.json.

Serverless is the catch, not the build. Three things VitNode can do need a process that is still alive between requests, and Vercel does not have one - and nothing there will run your migrations either.

What works, and what does not

FeatureOn VercelWhy
The site, SSR, and /api/*✅ WorksOne Nitro function answers every route.
Postgres, Redis, email, AI✅ WorksAll reached over the network. Bring managed ones.
S3 / R2 or Supabase storage✅ WorksUploads go straight to the bucket.
Local file storage❌ Not durableThe adapter writes to public/uploads on a filesystem that disappears.
WebSockets❌ UnsupportedThe /ws upgrade needs a long-lived serve() with a WebSocket server.
In-process cron❌ UnsupportedThe adapter runs a */1 * * * * timer inside the process.
Migrating on deploy⚠️ Your jobNothing on Vercel runs db:migrate for you. See Migrations.

Deploy it

Bring a managed Postgres

Anything reachable over the internet: Supabase, Neon, RDS, your own box. Create the database and keep the connection string.

Use the pooled connection string if your provider offers one. VitNode connects with postgres.js, which opens a pool of up to 10 connections per process, and a serverless platform will happily run a lot of processes at once. If you would rather keep the direct string, shrink the pool instead:

src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  dbProvider: drizzle({
    connection: { url: POSTGRES_URL, max: 1 }, 
    relations: coreRelations,
  }),
})

Set the environment variables first

In the Vercel project, before the first build - two of them are compiled into the browser bundle and cannot be changed afterwards without building again.

VariableNotes
POSTGRES_URLRequired. Nothing works without it.
NEXT_PUBLIC_WEB_URLYour production origin. Email links, SSO callbacks and password-reset links use it.
CRON_SECRETRequired if anything schedules cron. A long random string.
REDIS_URL REDIS_PASSWORDStrongly recommended - see the rate-limiter gotcha below.
NEXT_PUBLIC_API_URLLeave it unset. The single app answers /api/* itself, and both halves work that out from the request.

Do not set NEXT_PUBLIC_API_URL on preview deployments

A preview gets a generated hostname, so no configured value can name it. Left unset, the server takes the API origin off the request being rendered and the browser falls back to the origin the page came from - which is right on every deployment, including ones that did not exist when you configured the project. Set it only when the API genuinely lives somewhere else.

Create the project

Import the Git repository in Vercel. Three settings matter:

SettingValue
Root DirectoryThe app folder - apps/web for a monorepo, the repository root otherwise.
Build CommandYour build script. For a Single App that is vite build.
Node.js Version22 or newer.

Leave the output directory alone. Nitro reads VERCEL from the build environment, resolves its vercel preset without being told to, and writes .vercel/output in the Build Output API format that Vercel picks up on its own.

Migrate the database

Not from Vercel. Run it yourself, against the production connection string, from a checkout of the commit you are deploying:

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

See Migrations for why this is not part of the build command, and what happens if you make it one anyway.

Register the first account

Deploy, open the site, and go to /register. The first account created in an empty database becomes the Administrator - so do it before you tell anybody the URL. Then sign in at /admin and open Core → System → Integrations to see which services actually came up.

Migrations

start never migrates, and on Vercel there is no start at all - your code is invoked per request. So the schema has to be changed by something outside the deployment, and there are exactly two honest ways to do it:

  1. From your own machine or CI, pointing POSTGRES_URL at production, as a deliberate step before you promote the build. This is the one to prefer.
  2. In front of the build command - pnpm db:migrate && pnpm build. It works, and it also runs on every preview deployment against whatever POSTGRES_URL that environment has.

db:migrate can generate as well as apply

db:migrate is vitnode migrate, which generates a migration for any uncommitted schema change, then applies everything pending, then seeds. On a deploy there should be nothing to generate - so commit your migrations/ folder, and treat a freshly generated migration on a build machine as a sign that something never made it into the repository.

Gotchas

Without Redis, the rate limiter counts per instance

It falls back to in-memory storage, and on serverless "in memory" means one counter per cold start. The limit is still there; it just stops meaning anything. Set REDIS_URL and the counters become shared - the same switch also gives you a real cache.

Vercel's own Cron Jobs cannot drive VitNode's cron

VitNode's endpoint is POST /api/@vitnode/core/cron with an Authorization: Bearer $CRON_SECRET header, and Vercel Cron Jobs invoke a path with a GET. Use a scheduler that can send a POST - GitHub Actions, an uptime service, anything on the REST API page - and point it at that path once a minute.

Leave cookieDomain unset

authorization.cookieDomain is empty by default, which makes every auth cookie host-only and therefore correct on a hostname nobody configured. Set it to your production domain and preview deployments stop being able to sign anyone in, because a browser rejects a Domain the response did not come from.

Swagger ships with the deployment

/api/swagger is mounted with no session check, so it is public on your production URL too. There is no proxy in front of a Vercel function to deny it at, so if that matters, keep the deployment behind Vercel's own access controls - see Swagger.

Need any of the three?

Run the app on a server instead. Long-lived WebSocket connections, an in-process scheduler and local disk are all ordinary there - see Self-hosted.

Next