WWebBuddyMarketplace
Install and run

Installation and deployment

Deploy YourDesk with Docker Compose, Docker, Node on a VPS or Vercel, behind HTTPS, run the migrations, check it is up and update it safely.

9 min readUpdated Oct 17, 2026Applies to YourDesk 1.0.0

On this page

This page takes a fresh server to a working production install. Pick one of the four options below; the HTTPS, checking and updating sections apply to all of them.

Note

Read Requirements first. In particular: Postgres for real data, HTTPS in front of the app, and BETTER_AUTH_URL set to your public origin.

Overview

  1. Create a Postgres database (or use the one in docker-compose.yml).
  2. Generate a secret and write your environment file.
  3. Build the app.
  4. Run the migrations. How depends on the option you pick.
  5. Start the app on a private port.
  6. Put an HTTPS reverse proxy in front of it.
  7. Open the site and sign up straight away: the first account becomes the platform admin. See First admin.

Prepare the environment

Generate the session secret:

Terminal
openssl rand -hex 32   # BETTER_AUTH_SECRET

The minimum production environment is three variables:

.env
DATABASE_URL=postgresql://user:password@db-host:5432/yourdesk
BETTER_AUTH_SECRET=paste-the-64-hex-characters-here
BETTER_AUTH_URL=https://desk.example.com

Why each one matters:

Variable If you leave it out
DATABASE_URL The app silently uses the embedded database on local disk instead of Postgres.
BETTER_AUTH_SECRET With NODE_ENV=production (the Docker image and Vercel), the server refuses to start and logs how to generate one. Without NODE_ENV=production it starts with a random secret per process: everyone is signed out on every restart, and saved mailbox passwords, payment keys and personal xAI keys are sealed with a key file in the data folder instead.
BETTER_AUTH_URL Sign-in only trusts localhost (or Vercel's own URLs), so a custom domain gets "Invalid origin". Stripe and PayPal checkout also fall back to http://127.0.0.1:8080 as the return address.

.env.example documents the optional variables; the full list is on Configuration.

Warning

Choose BETTER_AUTH_SECRET once and keep it. Besides signing sessions, it is the key that encrypts stored mailbox passwords, Stripe and PayPal secrets and personal xAI keys. If it changes, those stored values can no longer be read and have to be entered again.

Option A: Docker Compose (app and Postgres)

The package includes a multi-stage Dockerfile and a docker-compose.yml that runs the app with Postgres 16.

  1. Create the environment file and set BETTER_AUTH_SECRET and BETTER_AUTH_URL:

    Terminal
    cp .env.example .env

    The compose file sets DATABASE_URL for the app itself, overriding the placeholder in .env.

  2. Before the first start, edit docker-compose.yml:

    • change the database password yourdesk in both POSTGRES_PASSWORD and the app's DATABASE_URL;
    • change the app's port mapping to "127.0.0.1:8080:8080" so only your reverse proxy can reach it.
  3. Build and start:

    Terminal
    docker compose up -d --build

The container's start command runs node scripts/migrate.mjs and then the server, so migrations apply on every start. The app listens on port 8080; database files persist in the yourdesk-pg volume. Follow the logs with docker compose logs -f app.

Option B: Docker with your own Postgres

Terminal
docker build -t yourdesk .
docker run -d --name yourdesk --restart unless-stopped \
  --env-file .env \
  -p 127.0.0.1:8080:8080 \
  yourdesk

Set DATABASE_URL in .env to your database. Migrations run when the container starts, before the server. The image runs as the unprivileged node user.

Warning

Without DATABASE_URL, the container falls back to the embedded database in /app/data, which disappears with the container unless you mount a volume there (-v yourdesk-data:/app/data). Use Postgres.

Share card hostname in Docker builds

VITE_PUBLIC_HOSTNAME (which turns on the /og.jpg link-preview image) is compiled in at build time. The Dockerfile does not pass it to the build, and .env is excluded from the build context, so setting it in --env-file has no effect. To use it, add two lines to the build stage of the Dockerfile, right after WORKDIR /app:

Dockerfile
ARG VITE_PUBLIC_HOSTNAME
ENV VITE_PUBLIC_HOSTNAME=$VITE_PUBLIC_HOSTNAME

Then build with docker build --build-arg VITE_PUBLIC_HOSTNAME=desk.example.com -t yourdesk ..

Option C: Node on a VPS

Terminal
npm ci
npm run build:node                              # builds .output/
node --env-file=.env scripts/migrate.mjs        # applies migrations to DATABASE_URL
PORT=8080 node --env-file=.env .output/server/index.mjs

Things that catch people out:

  • The migration runner and the server do not read .env. Pass --env-file=.env (Node 22 supports it) or export the variables. Without them the runner prints DATABASE_URL not set — skipping and exits successfully.
  • npm run build:node runs the migration runner at its end, but with the same caveat: it only migrates if DATABASE_URL is exported in the shell that runs the build.
  • The build itself does read .env, so VITE_PUBLIC_HOSTNAME there is compiled in.
  • Without PORT, the built server listens on 3000. The variables HOST, NITRO_PORT and NITRO_HOST are also honoured.
  • Set NODE_ENV=production in the server's environment, as the unit below does. The server then refuses to start without BETTER_AUTH_SECRET instead of quietly using a random one.

A minimal systemd unit:

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

[Service]
WorkingDirectory=/opt/yourdesk
EnvironmentFile=/opt/yourdesk/.env
Environment=NODE_ENV=production
Environment=PORT=8080
Environment=HOST=127.0.0.1
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
User=yourdesk

[Install]
WantedBy=multi-user.target

On Railway, Render or Fly.io, either point the platform at the Dockerfile, or use npm ci && npm run build:node as the build command and node scripts/migrate.mjs && npm start as the start command (platforms inject variables into the environment, so no --env-file is needed there).

Option D: Vercel

  1. Create a Postgres database (Neon's pooled connection string works well) and push the project to a Git repository.
  2. Import it in Vercel. vercel.json sets the build command (npm run build) and the install command; leave the output directory empty.
  3. Add DATABASE_URL and BETTER_AUTH_SECRET for Production and Preview. The build runs the migrations, so the build step needs DATABASE_URL. Vercel runs the server with NODE_ENV=production, so without BETTER_AUTH_SECRET the deployment builds but every request fails.
  4. Deploy, then attach your domain and set BETTER_AUTH_URL=https://your-domain (and VITE_PUBLIC_HOSTNAME, which needs a redeploy because it is compiled in).

Until BETTER_AUTH_URL is set, sign-in trusts Vercel's own deployment URLs. Set it before you enable payments: checkout return addresses come from it.

Run the migrations

The schema is the plain SQL files in migrations/, applied in file-name order. Each file runs inside a transaction and is recorded in a _migrations table, so a file never runs twice and re-running the runner is safe.

Where How migrations run
Postgres, Docker image Automatically when the container starts, before the server
Postgres, Vercel During npm run build
Postgres, Node node --env-file=.env scripts/migrate.mjs (or during npm run build:node if DATABASE_URL is exported)
Embedded database Automatically when the app starts

The app never migrates a real Postgres by itself at runtime. The runner takes no lock, so if you run several app containers, start one first and let it finish migrating before the others start.

HTTPS and the reverse proxy

The proxy has three jobs: terminate TLS, forward the original Host header, and pass the visitor's address in X-Forwarded-For. Sign-in attempts are rate limited per address (10 per 15 minutes), and the address comes from the first value of X-Forwarded-For. Without that header every visitor shares one limit.

Caddy

Caddy obtains certificates automatically and sets the forwarding headers itself:

Caddyfile
desk.example.com {
  reverse_proxy 127.0.0.1:8080
}

nginx

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

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

  # Invoice and portal file uploads are sent as data URLs of up to about 1.2 MB.
  client_max_body_size 5m;

  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    # Overwrite rather than append, so a visitor cannot choose their own address.
    proxy_set_header X-Forwarded-For $remote_addr;
  }
}

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

The app adds no HSTS header of its own; add one at the proxy if you want it.

Check the deployment

Version 1.0.0 has no dedicated health-check endpoint. Use these instead:

Terminal
# The sign-in page renders on the server: expect HTTP 200.
curl -s -o /dev/null -w "%{http_code}\n" https://desk.example.com/login

# Small JSON reply, without touching the database.
curl -s https://desk.example.com/api/demo

For a container health check, the Alpine image has wget. In docker-compose.yml:

YAML
services:
  app:
    # ...existing settings...
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/login || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3

Neither proves the database is reachable. After your first sign-up, Ops → System shows the database mode, whether BETTER_AUTH_SECRET and an xAI key are set, and how many migrations are applied.

Updating to a new version

  1. Back up the database (and the data folder if you use the embedded database). Keep your .env unchanged, above all BETTER_AUTH_SECRET.
  2. Read the Changelog for anything that needs attention.
  3. Copy the new release over your project, keeping .env and any files you customised. If you changed the source, merge the new release on a branch first.
  4. Rebuild and migrate:
    • Docker Compose: docker compose up -d --build (migrations run as the new container starts).
    • Node: npm ci && npm run build:node, then node --env-file=.env scripts/migrate.mjs, then restart the service.
    • Vercel: push; the build migrates.
  5. Sign in and check Ops → System.

Migrations are additive and already-applied files are skipped.

Backups

  • Postgres: use your provider's backups or pg_dump on a schedule. Files (logos and portal uploads) are stored inside the database, so a database backup includes them.
  • Embedded database: stop the app and copy the data folder.
  • Keep BETTER_AUTH_SECRET with your backups, stored separately. Without it, a restored database works but its stored mailbox passwords, payment keys and personal xAI keys cannot be decrypted.
  • Each studio can also download its own records as JSON under Settings → Workspace → Studio data. That export cannot be imported back; it is a copy for the studio, not a restore path.

Demo mode

To run a public showcase like the official demo, set:

.env
YOURDESK_DEMO=1

The sign-in page then shows Log in to demo account, which creates (on first use) and opens a demo studio filled with sample clients, projects, proposals and invoices. The demo account has no platform-admin rights and never becomes the first admin.

Demo mode is on only in two cases:

  • YOURDESK_DEMO=1 is set, or
  • it is a local trial: npm run dev with no DATABASE_URL, opened on localhost or 127.0.0.1.

A production build, a real database or any other address never turns it on by itself. When demo mode is off, the button is hidden, the demo account cannot sign in, and any session it still has is refused.

While demo mode is on, the demo account is read-only. The server refuses every change it tries, including changing its password, email or profile and signing other sessions out, and the app shows a notice saying changes are switched off. Because it cannot run the Agent or AI drafting, demo visitors do not spend your xAI credit.

Warning

Two things are still open on a showcase site. The demo studio's proposal, invoice and portal links are public and still accept client actions, so a visitor can sign or decline a demo proposal, post portal messages, upload files and approve deliverables. And nothing resets the demo data automatically. Reset the database on a schedule, and leave YOURDESK_DEMO unset on a real install. If sign-up is open, visitors can also create their own accounts, which use your platform XAI_API_KEY if one is set.