Self-hosting Voysse
Run and operate your own Voysse instance.
Run and operate your own Voysse instance. For a feature overview see the README.
Voysse is orchestrated by Docker Compose, with a Makefile wrapping the
common commands. A lightweight gateway (Caddy) is the single public entry
point: it serves the app and routes /api/* to the backend, so the frontend and
API share one origin.
| Service | Image | Role |
|---|---|---|
proxy | Caddy | HTTP gateway — the app's single origin (routes /api/* → backend). |
web | Next.js | Agency dashboard, client portal, playground, widget (internal). |
api | FastAPI | REST API, models, AI/knowledge/provider services (internal). |
db | PostgreSQL | All data (encrypted secrets at rest, internal). |
whatsapp | Node.js + Baileys | WhatsApp Web bridge (internal). |
Only the gateway is meant to be public. For HTTPS, put your own reverse proxy in front of it (see Go to production). One instance = one agency (the first registered user is its admin).
Why Caddy for the gateway? The routing is deliberately simple — two upstreams and one rule (
/api/*→ backend, everything else → frontend) — and authentication lives in the API (JWT in an httpOnly cookie), not at the edge. A single small binary with a tiny config fits that; heavier programmable gateways (Envoy, Kong) earn their keep with many services and edge auth/key validation, which this stack does not need. The gateway is isolated to theproxyservice, so swapping it later touches nothing else.
Contents
- Before you begin
- Install
- Access your install
- Secure your install
- Go to production (HTTPS)
- Environment variables
- Manage your install
- Backups
- Upgrade
- Uninstall
- Run without Docker
- Connect WhatsApp
- Tests
- WhatsApp / Baileys caveats
Before you begin
You need a host with Docker:
- macOS / Windows — Docker Desktop.
- Linux / a server — Docker Engine with the Compose plugin.
Check it is running:
docker --version
docker compose versionFor a public deployment you also need a domain and a server with ports 80 and 443 open (see Go to production).
Install
git clone <REPOSITORY_URL>
cd openvoiss
./scripts/generate-docker-env.sh # writes .env.docker with random secrets (gitignored)
make up # build, start, run migrationsmake up builds the images, starts the containers, creates the database and
applies the Alembic migrations. Every service should report healthy:
make psOverride host ports inline when they clash with other services:
API_PORT=8001 WEB_PORT=3001 DB_PORT=5433 make upRun from prebuilt images (no local build)
Tagged images are published to the GitHub Container Registry on every push to
main, so a server can skip building and pull them instead:
make pull # docker compose pull + up -dThis pulls ghcr.io/kanazawa-dev/openvoiss-{api,web,whatsapp}:latest. Pin a release
with OPENVOISS_VERSION=v1.2.3 make pull, or point at your own registry with
OPENVOISS_IMAGE_PREFIX. The prebuilt web image calls the API through the
gateway with relative /api; to target an API on a separate origin you must
build the frontend yourself with NEXT_PUBLIC_API_URL set.
Access your install
- App / dashboard — http://localhost:3000 (the gateway)
- API docs — http://localhost:8000/docs (the API is exposed locally for tooling)
- PostgreSQL —
make shell-db(or connect tolocalhost:5432)
On the first screen choose Create agency; that account is the admin. Only the gateway is meant to be reachable publicly; the web, API, database and WhatsApp bridge stay on the private Compose network.
Secure your install
Do this before exposing Voysse to anyone else.
- Secrets.
generate-docker-env.shfillsSECRET_KEY,ENCRYPTION_KEY,WHATSAPP_BRIDGE_TOKENandPOSTGRES_PASSWORDwith random values. If you set them by hand, use long random strings and never reuse them across installs. - ⚠️
ENCRYPTION_KEYmust never change once secrets are stored — it decrypts the provider API keys and the WhatsApp sessions. Losing or changing it makes them unrecoverable. - Keep services private. Leave
BIND_HOST=127.0.0.1(the default) so the database, API and frontend are only reachable from the host; expose the app to the internet only through the reverse proxy (next section). Never publish the PostgreSQL port on a public interface. - Do not commit
.env.dockeror any backup that contains it; store it in a secret manager. - Provider API keys are encrypted at rest and never returned in full to the
browser; the WhatsApp auth state and QR are encrypted too.
WHATSAPP_BRIDGE_TOKENauthenticates the backend↔bridge calls — do not reuse it as a password or key. - Rate limiting. Public, unauthenticated endpoints are throttled per client
IP: sign-in and sign-up (agency and portal) to blunt brute force, and the web
widget's message endpoint because each call spends LLM tokens. Limits are
in-memory (fine for one instance); set
RATE_LIMIT_ENABLED=falseif a proxy in front already enforces limits, or add proxy-level limits for a scaled setup. The limiter reads the client fromX-Forwarded-For, which the gateway sets.
Go to production (HTTPS)
The stack serves plain HTTP on the gateway. For a public deployment, put your own reverse proxy (Caddy, nginx, Traefik, a cloud load balancer…) in front of the gateway to terminate TLS with your domain — the usual self-hosting model.
- Run the stack; the gateway listens on
${WEB_PORT}(default3000), bound to127.0.0.1. - Point your reverse proxy at
127.0.0.1:${WEB_PORT}and serve your domain over HTTPS. Because the app and API share one origin, proxy the whole domain to that single port — there is nothing else to route. - Set
COOKIE_SECURE=truein.env.dockerand restart, so the session cookie is only sent over TLS.
Example with Caddy (automatic HTTPS) running on the host:
agency.example.com {
reverse_proxy 127.0.0.1:3000
}Keep the database, API and WhatsApp bridge private (BIND_HOST=127.0.0.1, the
default); only your reverse proxy should face the internet.
Custom domains for client portals
By default a client's portal lives at your-domain/portal/<slug>. You can also
serve it under the client's own domain (e.g. chat.brand.com) for a fully
white-label experience. This is opt-in because it requires the gateway to
terminate TLS and obtain certificates on demand.
1. Enable the multi-domain gateway. Create a docker-compose.override.yml
next to docker-compose.yml (Compose merges it automatically):
services:
proxy:
volumes:
- ./docker/Caddyfile.ondemand:/etc/caddy/Caddyfile:ro
- caddy_data:/data # persist issued certificates across restarts
environment:
PRIMARY_DOMAIN: app.youragency.com # your main domain
ports:
- "80:80"
- "443:443"
volumes:
caddy_data:Point app.youragency.com (and every client domain) at this server, then
make up. The gateway obtains a certificate for the primary domain normally, and
for each client domain on demand — but only after asking the API whether that
domain is a verified portal domain (/api/public/portal-domain), so it never
issues certificates for arbitrary hosts aimed at your IP.
2. Add the domain for a client. In the dashboard open the client → Portal tab → Custom domain, enter the domain and save. Create the two DNS records it shows:
| Type | Host | Value |
|---|---|---|
CNAME | chat.brand.com | your gateway host (e.g. app.youragency.com) |
TXT | _openvoiss-challenge.chat.brand.com | the token shown |
Click Verify. Once the TXT record is found the domain is marked verified, the gateway starts issuing its certificate, and the frontend routes the domain to that client's portal. The portal must be published for the domain to serve.
Without the override the app stays single-origin behind your own reverse proxy, and portals are reachable only at
/portal/<slug>.
Environment variables
generate-docker-env.sh fills the secrets. To set values by hand, copy
.env.docker.example to .env.docker and replace every CHANGE_*.
| Variable | Scope | Use |
|---|---|---|
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD | Private network | Main PostgreSQL database. |
POSTGRES_TEST_DB | Private network | Isolated database for pytest. |
SECRET_KEY | Backend | Signs the agency and portal sessions. |
ENCRYPTION_KEY | Backend / persisted data | Encrypts API keys, QR and the WhatsApp session. Must not change after secrets are stored. |
WHATSAPP_BRIDGE_TOKEN | Backend + bridge | Authenticates the private backend↔bridge calls. |
FRONTEND_URL | Backend | Origin allowed by CORS (only needed if you serve the API on a separate origin). |
NEXT_PUBLIC_API_URL | Browser / frontend build | Leave empty (default): the browser calls the API through the gateway with relative /api. Set it only to point the frontend at an API on a separate origin (baked at build time). |
COOKIE_SECURE | Backend | true behind HTTPS so the session cookie is only sent over TLS. |
COOKIE_SAMESITE | Backend | lax (default); none when the frontend and API are on different sites (requires COOKIE_SECURE=true). |
ACCESS_TOKEN_MINUTES | Backend | Session lifetime. |
WHATSAPP_LOG_LEVEL | Bridge | Log level; silent avoids exposing sensitive data. |
API_PORT, WEB_PORT, DB_PORT | Host | Host ports (defaults 8000 / 3000 / 5432). |
BIND_HOST | Host | Bind address: 127.0.0.1 (local) or 0.0.0.0 (expose directly). |
Manage your install
make logs # follow logs from all services (SERVICE=api to filter)
make ps # service status
make stop # stop containers (keep them)
make start # start stopped containers
make restart # restart all services
make migrate # apply Alembic migrations in the running api container
make shell-api # shell inside the api container
make shell-db # psql inside the database
make down # stop and remove containers (keeps data volumes)Run make help for the full list.
Backups
Export PostgreSQL without stopping the app:
mkdir -p backups
docker compose --env-file .env.docker exec -T db \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > backups/openvoiss.dumpAlso store .env.docker in a secret manager: a backup with API keys or a
WhatsApp session needs the same ENCRYPTION_KEY to be decrypted.
Restore (replaces data in the target database — back up first):
docker compose --env-file .env.docker stop api whatsapp
docker compose --env-file .env.docker exec -T db \
sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner' < backups/openvoiss.dump
docker compose --env-file .env.docker start api whatsappUpgrade
git pull
make up # rebuilds and restarts; your reverse proxy stays in frontNew images are built and the backend runs alembic upgrade head on start, so
schema changes are applied automatically. Take a backup first.
Uninstall
make down removes the containers and network but keeps your data. To delete
everything — database, PDFs, keys and WhatsApp sessions in the volumes:
make destroy # irreversibleRun without Docker
Requirements: Python 3.12+, Node.js 20+, PostgreSQL 14+ running.
# 1) databases
psql -d postgres -c "CREATE ROLE openvoiss LOGIN PASSWORD 'openvoiss';"
createdb -O openvoiss openvoiss
createdb -O openvoiss openvoiss_test
# 2) env
cp .env.example .env # then set SECRET_KEY and ENCRYPTION_KEY
# 3) backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r apps/api/requirements.txt
cd apps/api && alembic upgrade head && uvicorn app.main:app --reload --port 8000
# 4) frontend (new terminal)
cd apps/web && npm install && npm run dev
# 5) WhatsApp bridge (new terminal)
cd apps/whatsapp && npm install && npm run startThe bridge listens only on 127.0.0.1:3101 and must stay running alongside the
backend. See .env.example for the full variable list (DATABASE_URL,
BACKEND_URL, WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_PORT, …).
Connect WhatsApp
- Open Clients → the client → Channels → WhatsApp → Configure.
- Choose one of that client's agents and click Connect with QR code.
- On the phone: WhatsApp → Settings → Linked devices → Link a device, scan the QR and wait for Connected.
Incoming messages appear in the agency Inbox and the client portal. Click
Take over to answer as a human (the AI pauses) and Return to AI to
resume. On restart the bridge reloads enabled sessions from PostgreSQL and
reconnects automatically — no new QR unless WhatsApp ends the session, the device
is unlinked or ENCRYPTION_KEY changes.
Tests
Inside Docker:
make test # backend pytest + rebuild the web/whatsapp validation stagesLocally:
cd apps/api && ../../.venv/bin/pytest -q # backend (needs the openvoiss_test DB)
cd apps/whatsapp && npm test && npm run build
cd apps/web && npm run lint && npm run buildRe-test the migrations from scratch:
cd apps/api && alembic downgrade base && alembic upgrade headWhatsApp / Baileys caveats
Baileys connects to the multi-device protocol of WhatsApp Web; the number is linked as an extra device via QR. It is not the official WhatsApp Business Cloud API, and this project is not affiliated with or endorsed by WhatsApp/Meta.
- WhatsApp may change its protocol or revoke a session/device without notice.
- Abusive automation, spam or mass sending can get a number restricted. Use only numbers authorized by each client and respect WhatsApp's terms.
- The QR links the account while valid — never share it or screenshot it publicly.
- The integration handles one-to-one conversations (text, plus transcribed voice notes and described images when the agent's capabilities are on). It ignores groups, statuses, newsletters, documents, locations, reactions and calls.
- One WhatsApp account belongs to one client; another client needs a different
number.
apps/whatsapp/package.jsonpins an exact Baileys version.
Voysse