Deployments
Self-Hosted Deployment
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. The only required dependency is a PostgreSQL database (Redis is optional for caching and multi-instance scaling).
Quick start
Run these 4 commands on your deployment server:
bun install --frozen-lockfile
bun run build
bun run db:migrate
bun startProduction processes do not automatically run migrations at boot. Running
db:migrate ensures the database schema matches your compiled application
before traffic is served.
Required Environment Variables
Configure these in your production .env file:
# Database (PostgreSQL 15+)
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:8000A 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 (Process Manager)
Keep VitNode running continuously with automatic restarts on crash:
# Install PM2
bun add -g pm2
# Start both web and API processes
pm2 start "bun start" --name "vitnode-web"
pm2 save
pm2 startup