Skip to content

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

StackServicesWhere it runs
callbotapi, worker, ui, PostgreSQLapplication host
gatewayvoice gateway, FreeSWITCH mediaDMZ 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 prod

The Start page generates both environment files from a short form; the deployment guide walks the whole sequence.

Files that matter

FileRole
deploy/swarm/docker-compose-callbot.ymlthe callbot stack — identical in every environment
deploy/swarm/docker-compose-gateway.ymlthe gateway stack — identical in every environment
deploy/swarm/env/callbot.{staging,prod}.envcallbot settings for that environment
deploy/swarm/env/gateway.{staging,prod}.envgateway settings for that environment
deploy/swarm/secrets/shared-tokens.<env>.envthe 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

ServiceVariableMeaning
uiNUXT_PUBLIC_API_BASEwhere the browser reaches the api — a public URL, not a service name
apiAPP_DB_URL, APP_DB_USERPostgreSQL, as the least-privilege runtime role that row-level security relies on
apiOWNER_DB_URL, OWNER_DB_USERthe role that runs schema migrations at startup
apiJWT_SECRET_FILEsigning key for studio logins, read from a mounted secret
apiCORS_ALLOWED_ORIGINSthe studio origins the browser is allowed to call from
apiVGW_BASE_URLthe voice gateway, for originating outbound calls
apiRATE_LIMIT_*token-bucket allowances; the login bucket is separate
workerAI_PROVIDERthe fallback provider for every capability
workerAI_LLM_PROVIDER, AI_TTS_PROVIDER, AI_ASR_PROVIDERoverride one capability at a time — the only way to combine providers
workerLLM_MAX_STEPS, LLM_MAX_TOKENS_PER_CALL, LLM_MAX_SECONDS_PER_CALLwhat one call may spend on models
vgwFS_ESL_HOST, FS_ESL_PORTthe media engine's Event Socket, on 127.0.0.1
vgwCALLBOT_API_URL, CALLBOT_WORKER_WS_URLthe callbot cluster, as reached from the DMZ
vgwSPOOL_DIR, TRUNK_CONF_DIRthe volumes shared with the media container
mediaTELCO_*, PBX3CX_*, EXTERNAL_IP, RTP_START/RTP_ENDthe 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:

  1. 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 bumps version.
  2. 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 (reloadxmlreloadaclkillgw for 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:

FieldContent
sipProfilethe 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
prefixRoutesdialled-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).

PLXY AI Call Center — built as five separated projects and one shared schema.