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
- Overview
- Prepare the environment
- Option A: Docker Compose (app and Postgres)
- Option B: Docker with your own Postgres
- Share card hostname in Docker builds
- Option C: Node on a VPS
- Option D: Vercel
- Run the migrations
- HTTPS and the reverse proxy
- Caddy
- nginx
- Check the deployment
- Updating to a new version
- Backups
- Demo mode
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
- Create a Postgres database (or use the one in
docker-compose.yml). - Generate a secret and write your environment file.
- Build the app.
- Run the migrations. How depends on the option you pick.
- Start the app on a private port.
- Put an HTTPS reverse proxy in front of it.
- 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:
openssl rand -hex 32 # BETTER_AUTH_SECRETThe minimum production environment is three variables:
DATABASE_URL=postgresql://user:password@db-host:5432/yourdesk
BETTER_AUTH_SECRET=paste-the-64-hex-characters-here
BETTER_AUTH_URL=https://desk.example.comWhy 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.
Create the environment file and set
BETTER_AUTH_SECRETandBETTER_AUTH_URL:cp .env.example .envThe compose file sets
DATABASE_URLfor the app itself, overriding the placeholder in.env.Before the first start, edit
docker-compose.yml:- change the database password
yourdeskin bothPOSTGRES_PASSWORDand the app'sDATABASE_URL; - change the app's port mapping to
"127.0.0.1:8080:8080"so only your reverse proxy can reach it.
- change the database password
Build and start:
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
docker build -t yourdesk .
docker run -d --name yourdesk --restart unless-stopped \
--env-file .env \
-p 127.0.0.1:8080:8080 \
yourdeskSet 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:
ARG VITE_PUBLIC_HOSTNAME
ENV VITE_PUBLIC_HOSTNAME=$VITE_PUBLIC_HOSTNAMEThen build with docker build --build-arg VITE_PUBLIC_HOSTNAME=desk.example.com -t yourdesk ..
Option C: Node on a VPS
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.mjsThings 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 printsDATABASE_URL not set — skippingand exits successfully. npm run build:noderuns the migration runner at its end, but with the same caveat: it only migrates ifDATABASE_URLis exported in the shell that runs the build.- The build itself does read
.env, soVITE_PUBLIC_HOSTNAMEthere is compiled in. - Without
PORT, the built server listens on 3000. The variablesHOST,NITRO_PORTandNITRO_HOSTare also honoured. - Set
NODE_ENV=productionin the server's environment, as the unit below does. The server then refuses to start withoutBETTER_AUTH_SECRETinstead of quietly using a random one.
A minimal systemd unit:
[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.targetOn 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
- Create a Postgres database (Neon's pooled connection string works well) and push the project to a Git repository.
- Import it in Vercel.
vercel.jsonsets the build command (npm run build) and the install command; leave the output directory empty. - Add
DATABASE_URLandBETTER_AUTH_SECRETfor Production and Preview. The build runs the migrations, so the build step needsDATABASE_URL. Vercel runs the server withNODE_ENV=production, so withoutBETTER_AUTH_SECRETthe deployment builds but every request fails. - Deploy, then attach your domain and set
BETTER_AUTH_URL=https://your-domain(andVITE_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:
desk.example.com {
reverse_proxy 127.0.0.1:8080
}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:
# 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/demoFor a container health check, the Alpine image has wget. In docker-compose.yml:
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: 3Neither 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
- Back up the database (and the
datafolder if you use the embedded database). Keep your.envunchanged, above allBETTER_AUTH_SECRET. - Read the Changelog for anything that needs attention.
- Copy the new release over your project, keeping
.envand any files you customised. If you changed the source, merge the new release on a branch first. - Rebuild and migrate:
- Docker Compose:
docker compose up -d --build(migrations run as the new container starts). - Node:
npm ci && npm run build:node, thennode --env-file=.env scripts/migrate.mjs, then restart the service. - Vercel: push; the build migrates.
- Docker Compose:
- Sign in and check Ops → System.
Migrations are additive and already-applied files are skipped.
Backups
- Postgres: use your provider's backups or
pg_dumpon 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
datafolder. - Keep
BETTER_AUTH_SECRETwith 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:
YOURDESK_DEMO=1The 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=1is set, or- it is a local trial:
npm run devwith noDATABASE_URL, opened onlocalhostor127.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.