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
- Create a Postgres database.
- Generate secrets and write your environment file.
- Build the image (or the Node build).
- Run the migrations. The app never does this by itself.
- Start the app on a private port.
- Put an HTTPS reverse proxy with WebSocket support in front of it.
- Check
/api/health, then create the first admin.
Prepare the environment
Generate three secrets:
openssl rand -base64 32 # CREDENTIAL_ENCRYPTION_KEY
openssl rand -base64 32 # BETTER_AUTH_SECRET
openssl rand -base64 24 # JOBS_SECRETCreate .env.production (keep it out of version control):
# 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
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:
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:
docker run --rm --env-file .env.production reception-ai npm run db:migrateThe migration runner (scripts/migrate.mjs):
- applies the files in
migrations/in name order, each inside its own transaction; - records each one in a
_migrationstable 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_URLis 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:
docker run -d --name reception-ai --restart unless-stopped \
--env-file .env.production \
-p 127.0.0.1:8080:8080 \
reception-aiLogs go to standard output: docker logs -f reception-ai.
Option B: Node on a VM
npm ci
npm run build:app
npm run db:migrate # reads DATABASE_URL from .env if present
node --env-file=.env .output/server/index.mjsnpm 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:
[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.targetHTTPS 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:
reception.example.com {
reverse_proxy 127.0.0.1:8080
}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
curl https://reception.example.com/api/health{"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":falsebut 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_SECRETset, it acceptsAuthorization: Bearer <JOBS_SECRET>from anywhere. - Without it, it only answers requests from the same machine that did not pass through a proxy.
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.
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 checkAdd 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:
railway ssh --service reception-ai -- npm run admin:grant -- you@example.com superNote
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
- Back up the database. Keep your
.envand especiallyCREDENTIAL_ENCRYPTION_KEYandBETTER_AUTH_SECRETunchanged. - Read the Changelog for anything that needs attention.
- Replace the code with the new release (keep your
.envfiles) and rebuild the image or runnpm ci && npm run build:app. - Run
npm run db:migratewith the new code before it takes traffic. Already-applied migrations are skipped. - 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 ordocker logs. - Back up the Postgres database on a schedule, and store
CREDENTIAL_ENCRYPTION_KEYseparately. Without the key, a database backup restores everything except the saved credentials.