WWebBuddyMarketplace
Install and run

Installation and deployment

Deploy LinkNest with Docker on a VPS behind an HTTPS proxy, let it migrate at start-up, check that it is up, deploy on Vercel instead, and update safely.

8 min readUpdated Oct 17, 2026Applies to LinkNest 1.0.0

On this page

This page takes a fresh server to a working production install with the included Dockerfile. The same steps work on Coolify or any host that builds and runs a Docker image. Vercel has its own section below.

Note

Read Requirements first. In particular: Postgres, HTTPS, and the NITRO_PRESET build setting.

Overview

  1. Create a Postgres database.
  2. Generate the secrets, list your admin email and write your environment file.
  3. Build the image, passing your public hostname as a build argument.
  4. Start the container on a private port. It runs the migrations, then the server.
  5. Put an HTTPS reverse proxy in front of it.
  6. Open the site and sign up with the email in ADMIN_EMAILS straight away to become the admin.

Prepare the environment

Generate two secrets, one for sessions and one for encrypting payment keys:

Terminal
openssl rand -base64 32   # BETTER_AUTH_SECRET
openssl rand -base64 32   # SETTINGS_ENCRYPTION_KEY

Create .env.production (keep it out of version control):

.env
# Database
DATABASE_URL=postgres://linknest:password@db-host:5432/linknest

# Sign-in: your public HTTPS origin, and the same host without the scheme
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=https://links.example.com
VITE_PUBLIC_HOSTNAME=links.example.com

# Admins: comma-separated emails. Register these accounts yourself before going public.
ADMIN_EMAILS=you@example.com

# Encrypts payment secrets saved in the admin panel. Keep it unchanged once set.
SETTINGS_ENCRYPTION_KEY=...

# Optional: AI co-pilot and subscriber email
# XAI_API_KEY=xai-...
# RESEND_API_KEY=re_...
# RESEND_FROM=hello@links.example.com

Why each one matters:

Variable If you leave it out
DATABASE_URL The app runs on an in-memory database and loses everything on restart. The migration step skips itself.
BETTER_AUTH_SECRET A random secret is generated per start, so everyone is signed out whenever the container restarts.
BETTER_AUTH_URL Sign-in works out its origin from each request. Setting it removes the guesswork behind a proxy and adds the origin to the trusted list.
VITE_PUBLIC_HOSTNAME If neither this nor BETTER_AUTH_URL names your domain, sign-in fails with "Invalid origin" and checkouts are refused as an untrusted origin. Without it, the custom-domain target and the default broadcast sender fall back to localhost on the server.
ADMIN_EMAILS Nobody can become an admin, so nobody can open /admin.
SETTINGS_ENCRYPTION_KEY Payment secrets are encrypted with BETTER_AUTH_SECRET instead, so changing the session secret later also makes the saved payment secrets unreadable. With neither set, payment secrets cannot be saved in the admin panel.

Payment keys can go here too, but are easier to manage in the admin panel; see Configuration before you put them in the environment. The full list of variables is on Configuration.

Option A: Docker

The Dockerfile builds in two stages on node:22-slim:

  • The build stage runs npx vite build with NITRO_PRESET=node-server. It deliberately does not run npm run build, which would try to migrate a database during the build.
  • The runtime stage installs production dependencies, copies the built server, the migrations/ folder and scripts/migrate.mjs, and runs as the unprivileged node user under tini. It sets PORT=3000 and HOST=0.0.0.0, and installs wget for health checks.
  • On start it runs node scripts/migrate.mjs && exec node .output/server/index.mjs.

Build the image

Variables whose names start with VITE_ are compiled into the browser bundle, so they must be passed at build time:

Terminal
docker build -t linknest \
  --build-arg VITE_PUBLIC_HOSTNAME=links.example.com \
  --build-arg VITE_SOCIAL_AUTH=false \
  .

Keep VITE_PUBLIC_HOSTNAME in .env.production as well: the server reads it again at runtime. If you change your domain, rebuild the image.

Start the container

Bind it to localhost and let the proxy face the internet:

Terminal
docker run -d --name linknest --restart unless-stopped \
  --env-file .env.production \
  -p 127.0.0.1:3000:3000 \
  linknest

Watch the first start with docker logs -f linknest. You should see one [migrate] applied … line per file, then [migrate] done — 16 migration(s) applied., then the server starting. On later starts the runner prints [migrate] up to date.

What the migration step does

scripts/migrate.mjs:

  • creates a _migrations table if it is missing;
  • applies the files in migrations/ in name order, each inside its own transaction, and records each one, so a file never runs twice and re-running is safe;
  • exits without doing anything when DATABASE_URL is not set;
  • exits with an error if a file fails, which stops the container from starting. Read docker logs for the [migrate] lines, which include the Postgres error code and detail.

Important

The runner takes no lock. If you run more than one replica, deploy so that only one container starts against a database that still has pending migrations, for example by starting one replica first and scaling up afterwards.

Docker Compose with Postgres

A complete single-server setup:

YAML
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: linknest
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: linknest
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "linknest"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  app:
    build:
      context: .
      args:
        VITE_PUBLIC_HOSTNAME: links.example.com
        VITE_SOCIAL_AUTH: "false"
    env_file: .env.production
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/"]
      interval: 30s
      retries: 3
    restart: unless-stopped

volumes:
  db-data:

With this file, DATABASE_URL in .env.production is postgres://linknest:change-me@db:5432/linknest.

Coolify

Create an application from your repository with the Dockerfile build pack, set the exposed port to 3000, and add the variables from .env.production. Mark VITE_PUBLIC_HOSTNAME and VITE_SOCIAL_AUTH as build variables so they reach the build stage. Coolify's proxy handles HTTPS for the domain you attach.

Option B: Node on a VM

Terminal
npm ci
NITRO_PRESET=node-server VITE_PUBLIC_HOSTNAME=links.example.com VITE_SOCIAL_AUTH=false npx vite build
node --env-file=.env.production scripts/migrate.mjs
PORT=3000 node --env-file=.env.production .output/server/index.mjs

Neither the migration script nor the built server reads .env on its own; pass --env-file or use your process manager's environment file. A minimal systemd unit:

INI
[Unit]
Description=LinkNest
After=network-online.target

[Service]
WorkingDirectory=/opt/linknest
EnvironmentFile=/opt/linknest/.env.production
Environment=PORT=3000
ExecStartPre=/usr/bin/node scripts/migrate.mjs
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
User=linknest

[Install]
WantedBy=multi-user.target

HTTPS and the reverse proxy

The proxy terminates TLS and forwards the original Host header. LinkNest uses no WebSockets and no long-lived connections, so a standard proxy configuration is enough.

Caddy

Caddy obtains certificates automatically:

Caddyfile
links.example.com {
  reverse_proxy 127.0.0.1:3000
}

nginx

nginx
server {
  listen 443 ssl http2;
  server_name links.example.com;

  ssl_certificate     /etc/letsencrypt/live/links.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/links.example.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

server {
  listen 80;
  server_name links.example.com;
  return 301 https://$host$request_uri;
}

If you put Cloudflare in front of the proxy, Insights also get each visitor's country from the cf-ipcountry header.

Client IP for rate limits

The public forms are rate limited per visitor IP. LinkNest takes the IP from the X-Real-IP header if present, otherwise from the last entry of X-Forwarded-For, which is the one your proxy appends. Set X-Real-IP at the proxy so a visitor cannot send their own:

nginx
proxy_set_header X-Real-IP $remote_addr;
Caddyfile
links.example.com {
  reverse_proxy 127.0.0.1:3000 {
    header_up X-Real-IP {remote_host}
  }
}

Behind Cloudflare or a load balancer, make the proxy pass on the visitor's address (for example nginx's real_ip module with Cloudflare's ranges), not the address of the hop in front of it. Otherwise all visitors share one limit. Coolify's proxy and Vercel set the header already.

If creators will use their own domains, the proxy must also accept those hosts and obtain certificates for them. See Custom domains.

Check the deployment

LinkNest has no dedicated health endpoint. Two checks between them tell you what you need:

Terminal
# Is the server answering? (This is what the Compose health check uses.)
curl -sI https://links.example.com/ | head -1

# Is the database answering? A sample page from the migrations needs a query to render.
curl -s https://links.example.com/u/kaysun | grep -o "<title>[^<]*"

The home page renders even when the database is unreachable, because its data loaders fall back to defaults, so do not rely on it alone. The second check fails if the migrations did not run or the database is down. (If you have already unpublished the sample pages, use one of your own.)

Then sign up immediately with the address in ADMIN_EMAILS and open /admin: see Admins and the admin panel.

Warning

Deploying on Vercel

The build targets Vercel when NITRO_PRESET is unset.

  1. Create a Vercel project from the repository. The framework preset is Vite; the build command is the package's npm run build.
  2. Create a Postgres database (Neon works well) and set DATABASE_URL. It must be available at build time: npm run build runs vite build and then the migrations.
  3. Set BETTER_AUTH_SECRET, BETTER_AUTH_URL, VITE_PUBLIC_HOSTNAME, VITE_SOCIAL_AUTH=false, ADMIN_EMAILS and SETTINGS_ENCRYPTION_KEY, then redeploy.
  4. Attach your domain in Vercel, which handles HTTPS. For creators' custom domains, add each one to the Vercel project as well.

On Vercel, Insights get country, region and city from Vercel's request headers.

Updating to a new version

  1. Back up the database (pg_dump). Keep your environment file, and in particular BETTER_AUTH_SECRET and SETTINGS_ENCRYPTION_KEY, unchanged.
  2. Read the Changelog for anything that needs attention.
  3. Replace the code with the new release, keeping your environment file. If you changed the source, merge the new release into your branch first.
  4. Rebuild the image with the same build arguments.
  5. Restart the container. Pending migrations run before the server starts; already-applied files are skipped.
  6. Check the logs for [migrate] lines and repeat the deployment checks.

On Vercel, a redeploy does steps 4 and 5 for you.

Logs and backups

  • Logs go to standard output and standard error, with tagged lines such as [migrate], [db], [auth] and [stripe webhook]. Read them with docker logs, journalctl or your platform's log viewer.
  • Back up Postgres on a schedule. The database holds everything, including uploaded pictures and the payment keys saved in the admin panel. Payment secrets are encrypted with SETTINGS_ENCRYPTION_KEY, but the other identifiers (publishable key, client and key IDs, PayPal webhook ID) are not, so protect the backups anyway, and keep the encryption key somewhere other than next to them: a backup restored without the same key loses its saved payment secrets.
  • Admin → Export downloads a JSON summary of pages, plans and settings with secrets masked. It is not a backup: it leaves out links, earnings, subscribers and accounts.