WWebBuddyMarketplace
Install and run

Installation and deployment

Deploy Reception AI with Docker or Node on a VPS, behind HTTPS with WebSocket support, run migrations, check health, enable demo mode and update safely.

8 min readUpdated Oct 17, 2026Applies to Reception AI 1.0.0

On this page

This page takes a fresh server to a working production install. The steps are the same on any host that runs a container or a long-lived Node process; Railway has its own section below.

Note

Read Requirements first. In particular: one always-on process, Postgres, HTTPS, and an OpenAI and xAI key.

Overview

  1. Create a Postgres database.
  2. Generate secrets and write your environment file.
  3. Build the image (or the Node build).
  4. Run the migrations. The app never does this by itself.
  5. Start the app on a private port.
  6. Put an HTTPS reverse proxy with WebSocket support in front of it.
  7. Check /api/health, then create the first admin.

Prepare the environment

Generate three secrets:

Terminal
openssl rand -base64 32   # CREDENTIAL_ENCRYPTION_KEY
openssl rand -base64 32   # BETTER_AUTH_SECRET
openssl rand -base64 24   # JOBS_SECRET

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

.env
# Database and secrets: environment only
DATABASE_URL=postgres://user:password@db-host:5432/receptionai
CREDENTIAL_ENCRYPTION_KEY=...
BETTER_AUTH_SECRET=...
JOBS_SECRET=...

# Your public HTTPS origin, with no trailing slash
APP_URL=https://reception.example.com
BETTER_AUTH_URL=https://reception.example.com

# Who may become the first super-admin
ADMIN_EMAILS=you@example.com

# Providers (these can also be set later in the admin panel)
OPENAI_API_KEY=sk-...
XAI_API_KEY=xai-...

Why each one matters:

Variable If you leave it out
DATABASE_URL The app uses an in-memory database and loses everything on restart.
CREDENTIAL_ENCRYPTION_KEY Saving any credential (a phone number, a connector, a secret setting) fails.
BETTER_AUTH_SECRET A random key is generated per start, so everyone is signed out on every restart and pending admin links stop working.
APP_URL Plivo calls hang up, Twilio calls fall back to the slower turn-based path, and Stripe checkout cannot return.
BETTER_AUTH_URL The sign-in origin is worked out from each request. Setting it explicitly avoids surprises behind a TLS-terminating proxy.

Set APP_URL in the environment even though the admin panel can also edit it: the sign-in layer reads its trusted hosts from the environment. The full list of variables is on Configuration.

Warning

Back up CREDENTIAL_ENCRYPTION_KEY somewhere safe, separately from the database. If it is lost, every stored credential becomes unreadable and has to be entered again.

Option A: Docker

The included Dockerfile builds a two-stage image on node:22-slim. The runtime stage runs as the unprivileged node user under tini, listens on port 8080, and starts node .output/server/index.mjs.

Build the image

Terminal
docker build -t reception-ai .

If you want Google sign-in, pass the client ID at build time. It is compiled into the browser bundle, and the server also needs it at runtime, so keep it in .env.production too:

Terminal
docker build -t reception-ai --build-arg VITE_GOOGLE_CLIENT_ID=1234.apps.googleusercontent.com .

Run the migrations

Run them before the first start and before every update, with the same image you are about to run:

Terminal
docker run --rm --env-file .env.production reception-ai npm run db:migrate

The migration runner (scripts/migrate.mjs):

  • applies the files in migrations/ in name order, each inside its own transaction;
  • records each one in a _migrations table and never re-runs a recorded file, so running it again is safe;
  • takes a Postgres advisory lock, so two deploys cannot migrate at the same time;
  • exits successfully without doing anything if DATABASE_URL is not set.

Important

The Dockerfile comment mentions a fly.toml release command, but no fly.toml ships with the package. Nothing runs migrations automatically: make npm run db:migrate a release or pre-deploy step on whatever host you use.

Start the container

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

Terminal
docker run -d --name reception-ai --restart unless-stopped \
  --env-file .env.production \
  -p 127.0.0.1:8080:8080 \
  reception-ai

Logs go to standard output: docker logs -f reception-ai.

Option B: Node on a VM

Terminal
npm ci
npm run build:app
npm run db:migrate          # reads DATABASE_URL from .env if present
node --env-file=.env .output/server/index.mjs

npm run build is build:app followed by db:migrate, which is convenient on a machine that can reach the database during the build. Note that npm start and node .output/server/index.mjs do not read .env on their own; pass --env-file or use your process manager's environment file. Without PORT, the built server listens on 3000.

A minimal systemd unit:

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

[Service]
WorkingDirectory=/opt/reception-ai
EnvironmentFile=/opt/reception-ai/.env
Environment=PORT=8080
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
User=receptionai

[Install]
WantedBy=multi-user.target

HTTPS and the reverse proxy

The proxy has three jobs: terminate TLS, forward the original Host header, and pass WebSocket upgrades for /api/voice/stream, which carries live call audio for up to 15 minutes.

Caddy

Caddy obtains certificates automatically and proxies WebSockets without extra configuration:

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

nginx

nginx
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 443 ssl http2;
  server_name reception.example.com;

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

  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    # Calls hold the socket open; do not cut them off at nginx's 60 s default.
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
  }
}

server {
  listen 80;
  server_name reception.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.

If you cannot run a proxy, the built server can serve HTTPS itself when both NITRO_SSL_CERT and NITRO_SSL_KEY are set (each a PEM string or a file path). A proxy is still the simpler choice for certificate renewal.

Check the deployment

Terminal
curl https://reception.example.com/api/health
JSON
{"status":"ok","database":"ok","databaseMs":4,"openaiKey":true,"uptimeSec":120,"checkedInMs":5}
  • It runs a real database query, so it answers "can this instance take a call", not just "is the process up".
  • A database failure returns 503 with "status":"degraded". Use it as your host's health check so a broken release does not replace a working one.
  • A missing OpenAI key is reported as "openaiKey":false but still returns 200, so the admin panel stays reachable for you to fix it.

Then sign up with an address from ADMIN_EMAILS and follow First admin and the admin panel.

Background jobs

Queued work (campaign dialling, outbound call reconciliation, Google Sheets rows) is stored in Postgres and drained every minute by a scheduled task inside the running server. You do not need cron or an external scheduler.

/api/jobs/run drains the queue on demand:

  • With JOBS_SECRET set, it accepts Authorization: Bearer <JOBS_SECRET> from anywhere.
  • Without it, it only answers requests from the same machine that did not pass through a proxy.
Terminal
curl -X POST https://reception.example.com/api/jobs/run \
  -H "Authorization: Bearer $JOBS_SECRET"

QSTASH_TOKEN is optional and not needed for jobs to run; see Configuration.

Deploying on Railway

The package includes .railway/railway.ts, an infrastructure file that describes a Postgres service, the app with npm run db:migrate as its pre-deploy command, a health check on /api/health, and one replica.

Terminal
railway init --name reception-ai
railway add --database postgres
railway add --service reception-ai
railway domain --service reception-ai --port 8080

railway variable set --service reception-ai \
  'DATABASE_URL=${{Postgres.DATABASE_URL}}' \
  APP_URL='https://<your-app>.up.railway.app' \
  PORT=8080 \
  ADMIN_EMAILS='you@example.com'

openssl rand -base64 32 | railway variable set --service reception-ai --stdin BETTER_AUTH_SECRET
openssl rand -base64 32 | railway variable set --service reception-ai --stdin CREDENTIAL_ENCRYPTION_KEY

railway up --service reception-ai

npm install              # the Railway SDK is a dev dependency
railway config plan      # preview; changes nothing
railway config apply     # attaches the migration step and health check

Add OPENAI_API_KEY and XAI_API_KEY the same way. On Railway, VITE_GOOGLE_CLIENT_ID belongs in the service's build settings as well as its variables. To promote an admin from inside the running service:

Terminal
railway ssh --service reception-ai -- npm run admin:grant -- you@example.com super

Note

The bundled DEPLOY.md also describes Fly.io, but its fly.toml is not included. If you deploy on Fly, write your own with internal_port = 8080, a release_command of npm run db:migrate, and machine auto-stop turned off.

Updating to a new version

  1. Back up the database. Keep your .env and especially CREDENTIAL_ENCRYPTION_KEY and BETTER_AUTH_SECRET unchanged.
  2. Read the Changelog for anything that needs attention.
  3. Replace the code with the new release (keep your .env files) and rebuild the image or run npm ci && npm run build:app.
  4. Run npm run db:migrate with the new code before it takes traffic. Already-applied migrations are skipped.
  5. Restart the app and check /api/health.

If you changed the source, apply the new release on a branch and merge your changes before step 3.

Demo mode

DEMO_MODE=true (also 1, yes or on) turns a deployment into a public showroom, like the official demo:

  • The sign-in page offers two one-click doors: a business owner and a platform admin. Both accounts and a seeded receptionist with hours and a diary are created the first time someone uses a door.
  • Every write by a demo account is refused on the server. Talking to the receptionist still works.
  • Sign-ups close, except for the demo accounts and addresses in ADMIN_EMAILS.
  • The Configuration screen hides the AI, telephony, email, payments and platform groups.

Warning

Conversations on a demo spend your AI budget for every visitor. Set a spending limit with your AI providers before you publish a demo URL, and leave DEMO_MODE unset on a real install.

Logs and backups

  • Logs go to standard output and standard error: JSON lines with secrets redacted, plus tagged lines such as [admin], [jobs] and [migrate]. Collect them with your platform's log viewer or docker logs.
  • Back up the Postgres database on a schedule, and store CREDENTIAL_ENCRYPTION_KEY separately. Without the key, a database backup restores everything except the saved credentials.