Appearance
Configuration
Everything is configured by environment — no config files inside images. The single source of truth for each stack's variables is its compose/Swarm file; this page maps the landscape.
The two stacks
| Stack | Services | Where it runs |
|---|---|---|
| callbot | api, worker, ui, PostgreSQL | application host |
| gateway | voice gateway, FreeSWITCH media | DMZ host (can be separate) |
Each stack is deployed per environment — staging and production — with its own environment file and its own secrets:
bash
deploy/swarm/secrets-init.sh callbot prod
deploy/swarm/deploy.sh callbot prodThe Start page generates both environment files from a short form; the deployment guide walks the whole sequence.
Files that matter
| File | Role |
|---|---|
deploy/swarm/docker-compose-callbot.yml | the callbot stack — identical in every environment |
deploy/swarm/docker-compose-gateway.yml | the gateway stack — identical in every environment |
deploy/swarm/env/callbot.{staging,prod}.env | callbot settings for that environment |
deploy/swarm/env/gateway.{staging,prod}.env | gateway settings for that environment |
deploy/swarm/secrets/shared-tokens.<env>.env | the three tokens both clusters must share |
The .example files next to each env file are the templates; the real ones are gitignored, because they describe your topology.
Credentials — database passwords, the JWT signing key, provider API keys, the ESL password — are Docker Swarm secrets, never environment values. The env files carry only the non-secret knobs.
Key variables by service
| Service | Variable | Meaning |
|---|---|---|
| ui | NUXT_PUBLIC_API_BASE | where the browser reaches the api — a public URL, not a service name |
| api | APP_DB_URL, APP_DB_USER | PostgreSQL, as the least-privilege runtime role that row-level security relies on |
| api | OWNER_DB_URL, OWNER_DB_USER | the role that runs schema migrations at startup |
| api | JWT_SECRET_FILE | signing key for studio logins, read from a mounted secret |
| api | CORS_ALLOWED_ORIGINS | the studio origins the browser is allowed to call from |
| api | VGW_BASE_URL | the voice gateway, for originating outbound calls |
| api | RATE_LIMIT_* | token-bucket allowances; the login bucket is separate |
| worker | AI_PROVIDER | the fallback provider for every capability |
| worker | AI_LLM_PROVIDER, AI_TTS_PROVIDER, AI_ASR_PROVIDER | override one capability at a time — the only way to combine providers |
| worker | LLM_MAX_STEPS, LLM_MAX_TOKENS_PER_CALL, LLM_MAX_SECONDS_PER_CALL | what one call may spend on models |
| vgw | FS_ESL_HOST, FS_ESL_PORT | the media engine's Event Socket, on 127.0.0.1 |
| vgw | CALLBOT_API_URL, CALLBOT_WORKER_WS_URL | the callbot cluster, as reached from the DMZ |
| vgw | SPOOL_DIR, TRUNK_CONF_DIR | the volumes shared with the media container |
| media | TELCO_*, PBX3CX_*, EXTERNAL_IP, RTP_START/RTP_END | the trunks and the audio path |
Every credential variable also has a *_FILE form, and that is what the stacks use: the service reads the file and resolves the value itself, so nothing sensitive passes through the environment.
The tables above are the ones that change between environments. Each image's Docker Hub page lists its own complete set.
Telephony trunks: the import contract
Carrier SBC addresses, ACL ranges and prefix-routing tables are operational data, not repository content — they identify production infrastructure and differ per deployment, so they are never committed. The static baseline (one carrier + one PBX) travels through the gateway env file (TELCO_*/PBX3CX_*); everything beyond it — trunk gateways, their ACL entries and the prefix→carrier routing table — is created and edited in the Studio UI (Admin → Telephony, STUDIO_ADMIN) and applied to the media engine without a redeploy:
- The Studio saves the document via
PUT /api/v1/admin/telephony-trunks(import a bundle, or edit in place); the api validates it whole, encrypts trunk passwords (write-only, like AI provider keys) and bumpsversion. - The voice gateway polls
GET /internal/v1/telephony-trunks(internal token) every minute and on ESL reconnect. On a version change it updates its prefix routing, renders gateway XMLs + the ACL fragment + the optional transfer override into the shared trunks volume (TRUNK_CONF_DIR↔ the media container's/etc/freeswitch/trunks), and applies them over ESL (reloadxml→reloadacl→killgwfor changed gateways only →rescan). Failures keep last-known-good.
The interchange format for that screen (and for hand-carried bundles) is a single JSON document, kind: "plxy.telephony-trunks", schemaVersion: 1:
| Field | Content |
|---|---|
sipProfile | the one sofia profile: port, context, codec prefs, DTMF model |
gateways[] | per trunk: name, role (carrier/transfer), proxy host[:port], register + optional credentials, callerIdInFrom, absoluteCodecString, executeOnAnswer |
aclAllow[] | CIDR + comment; rendered into the telco-trunk default-deny list |
prefixRoutes | dialled-prefix → gateway-name map, plus defaultGateway and transferGateway |
Rules: gateway name is the FreeSWITCH gateway name (sofia/gateway/<name>/…) — lowercase, stable, never reused for a different carrier; credentials are present only when register: true and are stored encrypted server-side, never re-exported; every proxy must be covered by an aclAllow entry or inbound from that trunk will be rejected. Generated bundles live under exports/ (gitignored).