Deployments
Self-hosted
Build, migrate, and run VitNode on your own server or Docker container with a reverse proxy.
VitNode compiles into a standard Node.js server and static assets. This guide builds the app on your own server, prepares the database, and keeps it running behind Nginx.
Before you begin
- Node.js
22.22or newer in the 22 line,24.11or newer in the 24 line, or26+. Running on Bun instead? Follow Bun first - the commands below stay the same. - A PostgreSQL database and its connection string. Redis is optional, for shared caching and running more than one instance.
- The production
.envdescribed in Required environment variables.
Quick start
Run these 4 commands in the app's folder on your server. For a Turborepo project, run them at the repository root. The root scripts run each app's own script.
bun install --frozen-lockfile
bun run build
bun run db:migrate
bun startProduction processes do not run migrations at boot. Run db:migrate after
build and before start, on every deploy.
db:migrate runs vitnode migrate, which reads the compiled plugins, generates any missing migrations, applies them and seeds the roles, languages and permissions VitNode needs. That is why it comes after build.
The first account that signs up on a fresh database becomes the administrator, so register your own account before you share the URL.
Required environment variables
Configure these in your production .env file:
# Database
POSTGRES_URL=postgresql://user:password@localhost:5432/vitnode_db
# The origin visitors reach. Email links, SSO callbacks and password-reset
# links are built from it.
VITNODE_WEB_URL=https://example.com
# Required if anything schedules cron. A long random string.
CRON_SECRET=change_me_to_a_long_random_secret_string
# How many proxies sit in front of the app: 1 behind the Nginx config below,
# 0 when the app is exposed directly. See the rate limiter docs.
VITNODE_TRUSTED_PROXY_HOPS=1
# Optional: Shared Cache & Realtime
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=change_me_redis_passwordWhere the API lives
A Single App answers /api/* itself, so leave VITNODE_API_URL unset.
Server-side calls (the session read, every loader) go straight to the API in the
same process - no HTTP round trip through your proxy or CDN - and the browser
falls back to the origin the page was served from, so the same build is correct
on every hostname you point at it.
A split deployment runs the web app and apps/api as two processes, and the
web app then has to be told where the API is - its own origin has no /api/* to
answer, so an unset value turns every server-side call into a 404, starting with
the session read on the first render. Set it in the web app's environment, and
set it before the build: it is compiled into the browser bundle too.
VITNODE_API_URL=http://localhost:8000In a split deployment, each app runs its own commands from its folder, or both at once from the Turborepo root:
| Step | apps/api | apps/web |
|---|---|---|
| Build | build | build |
| Migrate | db:migrate (the API owns the database) | - |
| Start | start, port 8000 by default | start, port 3000 by default |
Pass --port to vitnode start to use another port.
A value set here wins over the request origin, which is why a Single App must
not carry one - http://localhost:3000 left over from development would point
production at the visitor's own machine.
Reverse proxy (Nginx configuration)
Proxy traffic to VitNode while forwarding client headers, keeping connections to the app open between requests, and upgrading only the requests that ask for a WebSocket:
# "upgrade" for WebSocket handshakes, and an empty Connection header for
# everything else so the upstream connection stays in the keepalive pool.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
upstream vitnode {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com;
# Compress HTML and API responses. Static assets already ship as .br/.gz
# from the build, and nginx never re-compresses a compressed response.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml application/manifest+json image/svg+xml;
# Brotli needs the ngx_brotli module. Uncomment if your nginx has it.
# brotli on;
# brotli_comp_level 5;
# brotli_types text/plain text/css text/xml application/json application/javascript application/xml application/manifest+json image/svg+xml;
location / {
proxy_pass http://vitnode;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Forwarded identity
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}keepalive 32 plus an empty Connection header reuses connections to the app instead of opening a fresh one for every request. Sending Connection "upgrade" on every request, as many copy-paste configs do, quietly turns that off.
In a split deployment, apps/api gzips its own JSON responses, so the same config works in front of it too.
Hashed files under /assets/ already leave the app with Cache-Control: public, max-age=31536000, immutable, so there is nothing to add for them here.
Keep proxy_set_header Host $host;. VitNode picks a language domain from the Host header and deliberately ignores X-Forwarded-Host, which any visitor can send.
Running with PM2
Keep VitNode running continuously with automatic restarts on crash:
# Install PM2
bun add -g pm2
# Single app, or a Turborepo root (starts every app)
pm2 start "bun start" --name "vitnode"
pm2 save
pm2 startupFor a split deployment without a Turborepo root, start each app from its folder as its own process, for example pm2 start "pnpm start" --name "vitnode-api" --cwd apps/api and pm2 start "pnpm start" --name "vitnode-web" --cwd apps/web.
Check the result
start prints Running with the URL once the server accepts connections. Point your load balancer or uptime monitor at /api/health/ready. Open your domain, sign in with the administrator account, and open the AdminCP Integrations page at /admin/core/system/integrations to see which services the deployment has connected.