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
- Overview
- Prepare the environment
- Option A: Docker
- Build the image
- Start the container
- What the migration step does
- Docker Compose with Postgres
- Coolify
- Option B: Node on a VM
- HTTPS and the reverse proxy
- Caddy
- nginx
- Client IP for rate limits
- Check the deployment
- Deploying on Vercel
- Updating to a new version
- Logs and backups
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
- Create a Postgres database.
- Generate the secrets, list your admin email and write your environment file.
- Build the image, passing your public hostname as a build argument.
- Start the container on a private port. It runs the migrations, then the server.
- Put an HTTPS reverse proxy in front of it.
- Open the site and sign up with the email in
ADMIN_EMAILSstraight away to become the admin.
Prepare the environment
Generate two secrets, one for sessions and one for encrypting payment keys:
openssl rand -base64 32 # BETTER_AUTH_SECRET
openssl rand -base64 32 # SETTINGS_ENCRYPTION_KEYCreate .env.production (keep it out of version control):
# 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.comWhy 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 buildwithNITRO_PRESET=node-server. It deliberately does not runnpm 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 andscripts/migrate.mjs, and runs as the unprivilegednodeuser undertini. It setsPORT=3000andHOST=0.0.0.0, and installswgetfor 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:
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:
docker run -d --name linknest --restart unless-stopped \
--env-file .env.production \
-p 127.0.0.1:3000:3000 \
linknestWatch 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
_migrationstable 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_URLis not set; - exits with an error if a file fails, which stops the container from starting. Read
docker logsfor 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:
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
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.mjsNeither 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:
[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.targetHTTPS 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:
links.example.com {
reverse_proxy 127.0.0.1:3000
}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:
proxy_set_header X-Real-IP $remote_addr;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:
# 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.
- Create a Vercel project from the repository. The framework preset is Vite; the build command is the package's
npm run build. - Create a Postgres database (Neon works well) and set
DATABASE_URL. It must be available at build time:npm run buildrunsvite buildand then the migrations. - Set
BETTER_AUTH_SECRET,BETTER_AUTH_URL,VITE_PUBLIC_HOSTNAME,VITE_SOCIAL_AUTH=false,ADMIN_EMAILSandSETTINGS_ENCRYPTION_KEY, then redeploy. - 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
- Back up the database (
pg_dump). Keep your environment file, and in particularBETTER_AUTH_SECRETandSETTINGS_ENCRYPTION_KEY, unchanged. - Read the Changelog for anything that needs attention.
- Replace the code with the new release, keeping your environment file. If you changed the source, merge the new release into your branch first.
- Rebuild the image with the same build arguments.
- Restart the container. Pending migrations run before the server starts; already-applied files are skipped.
- 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 withdocker logs,journalctlor 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.