Skip to Content
Documentation

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.22 or newer in the 22 line, 24.11 or newer in the 24 line, or 26+. 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 .env described 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.

Deploy VitNode
bun install --frozen-lockfile
bun run build
bun run db:migrate
bun start
Always run db:migrate before start

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

.env
# 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_password

Where 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.

apps/web/.env
VITNODE_API_URL=http://localhost:8000

In a split deployment, each app runs its own commands from its folder, or both at once from the Turborepo root:

Stepapps/apiapps/web
Buildbuildbuild
Migratedb:migrate (the API owns the database)-
Startstart, port 8000 by defaultstart, 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:

/etc/nginx/sites-available/vitnode.conf
# "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:

Run VitNode with PM2
# Install PM2
bun add -g pm2

# Single app, or a Turborepo root (starts every app)
pm2 start "bun start" --name "vitnode"
pm2 save
pm2 startup

For 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.