Appearance
Deployment guide — PLXY AI Call Center
From two clean hosts to a call that completes: what to install, how the two Docker stacks fit together, what crosses the firewall between them, and how to connect your carrier and your 3CX.
This guide covers the two environments you run: staging and production. They use the same images, the same stack files and the same secret handling — staging exists so that a release is rehearsed exactly as production will run it.
In a hurry? The Start page generates both environment files and your command sequence from a short form, then sends you back here.
Contents: Prerequisites · Deploy · Configuration · First sign-in · Carrier and 3CX · Staging · Operating · Troubleshooting
1. Prerequisites
Docker Engine with Compose v2 is the only requirement. The images are published and self-contained, so no host needs a JDK, Maven or Node — on either machine.
| Requirement | Check |
|---|---|
| Docker Engine + Compose v2 | docker info |
bash, curl (for the deployment scripts) | — |
You will need two hosts for the recommended layout — one for the application stack, one in the DMZ for telephony — though a single host runs both for a pilot. Each host is its own single-node Docker Swarm; §2 explains why they are not joined into one.
From the carrier and the PBX, before you start:
| From | What you need |
|---|---|
| Your Telco | The SBC addresses to allow, and whether the trunk is IP-authenticated or registered |
| Your 3CX | A SIP trunk pointing at this deployment, and the extension or queue numbers transfers should reach |
| Your network | The public IP of the DMZ host, and the firewall rules in §2 |
The stack files and deployment scripts live in the repository:
bash
git clone https://github.com/SengPhirum/PLXY_AICC.git
cd PLXY_AICCNothing is built from it. The clone is for deploy/swarm/ — two stack files, two scripts and the environment templates.
2. Deploy
Deployment is Docker Swarm, split into two stacks so the telephony half can live on a DMZ host of its own:
| Stack | File | Services | Runs on |
|---|---|---|---|
| callbot | deploy/swarm/docker-compose-callbot.yml | callbot-api, callbot-worker, callbot-ui, postgres-db | internal cluster |
| gateway | deploy/swarm/docker-compose-gateway.yml | vgw-app, vgw-media | DMZ host |
The same two files serve staging and production — what differs is the environment file (deploy/swarm/env/<stack>.<staging|prod>.env), which carries addresses, image coordinates and sizing. No credentials live in those files: every secret is a Swarm secret.
Why two Swarms, not one
The two stacks are deployed to separate Swarms. Joining the DMZ host to the internal cluster would need Swarm's control and data planes open across the boundary — 2377/tcp, 7946/tcp+udp and 4789/udp — which is most of what a DMZ exists to prevent. Keeping them separate means exactly three ports cross the firewall.
The cost is that the stacks cannot resolve each other by service name, so every cross-stack address is explicit configuration. Traffic goes both ways:
internal cluster DMZ host
┌────────────────────────┐ ┌────────────────────────┐
originate ───▶ │ │─────▶│ vgw-app :8080 │ ──▶ Telco / 3CX
TTS upload ──▶ │ callbot-api :29092 │ │ vgw-media :5060, RTP │
│ callbot-worker :29192 │◀─────│ │
│ callbot-ui :9092 │ └────────────────────────┘
│ postgres-db (private) │ session WebSocket +
└────────────────────────┘ call results| Direction | Port | Purpose | Set in |
|---|---|---|---|
| callbot → DMZ | 8080/tcp | originate calls, upload rendered TTS | VGW_BASE_URL (callbot env) |
| DMZ → callbot | 29192/tcp | worker session WebSocket, one per call | CALLBOT_WORKER_WS_URL (gateway env) |
| DMZ → callbot | 29092/tcp | report finished call results | CALLBOT_API_URL (gateway env) |
| carrier → DMZ | 5060/udp+tcp, 16384-16484/udp | SIP and RTP | carrier + TELCO_ALLOWED_NETS |
Nothing needs to reach postgres-db: it is on a separate overlay that only callbot-api joins, so a compromise of the worker or the studio cannot reach the database at all.
Where the images come from
By default, from Docker Hub — there is nothing to build. Both stacks resolve docker.io/phirumseng/plxy-* at the tag their environment file pins, so a deployment host only needs to be able to reach Docker Hub.
Pin a real TAG rather than latest: latest makes a rolling update unrepeatable and a rollback meaningless, and deploy.sh warns when it sees it.
Publishing to your own registry instead
Point REGISTRY at it and push there — useful when the deployment hosts have no route to Docker Hub, or when policy requires images to be held internally:
bash
REGISTRY=registry.example.com:5000 TAG=1.0.0 deploy/swarm/build-images.sh --pushThen set the same REGISTRY in both stacks' environment files.
If the DMZ host has no egress at all, move the images across without a registry: docker save … | ssh dmz docker load.
Deploy the callbot stack (internal cluster)
bash
cp deploy/swarm/env/callbot.prod.env.example deploy/swarm/env/callbot.prod.env
$EDITOR deploy/swarm/env/callbot.prod.env # VGW_BASE_URL, PUBLIC_API_BASE, REGISTRY, TAG
docker node update --label-add plxy_data=true <node> # where the database volume lives
deploy/swarm/secrets-init.sh callbot prod
deploy/swarm/deploy.sh callbot prodsecrets-init.sh generates the cluster's secrets and, on its first run, writes the three tokens shared with the gateway to deploy/swarm/secrets/shared-tokens.prod.env. Move that file to the DMZ host before deploying there — if the two clusters hold different values, every cross-stack call fails with 401 and nothing else explains why.
That file is per environment on purpose. One file for both would hand production the tokens staging already has, on a host where more people have shell access.
Deploy the gateway stack (DMZ host)
bash
docker swarm init # the DMZ host is its own single-node Swarm
docker node update --label-add plxy_role=gateway <node>
cp deploy/swarm/env/gateway.prod.env.example deploy/swarm/env/gateway.prod.env
$EDITOR deploy/swarm/env/gateway.prod.env # CALLBOT_*_URL, TELCO_*, PBX3CX_*, EXTERNAL_IP
# with shared-tokens.prod.env copied from the callbot host:
deploy/swarm/secrets-init.sh gateway prod
deploy/swarm/deploy.sh gateway prodBoth gateway services run on the host network. That is not a shortcut — Swarm cannot publish a port range in host mode (the long syntax rejects a range; the short syntax silently gives you ingress mode, which SNATs and strands RTP), so the media engine has to be on the host network for 16384-16484/udp to work at all. Swarm forbids mixing the host network with an overlay, so vgw-app joins it too — which is the better arrangement anyway: the Event Socket is reached on 127.0.0.1 and ESL_LISTEN_IP=127.0.0.1 keeps port 8021 off the DMZ host's carrier-facing interface entirely.
Consequences: ports are bound directly on the DMZ host with no ingress mesh in front, and each service is one replica (host-networked tasks would collide on the same ports, and one ESL consumer per FreeSWITCH is required regardless).
Watching it converge
deploy.sh submits the stack and returns; convergence is asynchronous.
bash
docker stack services plxy-callbot
docker service ps plxy-callbot_callbot-api --no-truncA task stuck in pending means an unsatisfiable placement constraint or a missing secret. deploy.sh preflights both and names what is wrong, so a pending task after a clean deploy is worth reading docker service ps for.
3. Configuration
Both stacks take their settings from their environment file, and every credential from a Swarm secret. The Start page writes both environment files for you; the full reference for every variable is Configuration.
The values you must set, because nothing can guess them:
| Variable | Stack | Purpose |
|---|---|---|
REGISTRY, TAG | both | which images, at which version — the same tag in both stacks |
VGW_BASE_URL | callbot | the DMZ gateway, as reached from the internal cluster |
CALLBOT_API_URL, CALLBOT_WORKER_WS_URL | gateway | the internal cluster, as reached from the DMZ |
PUBLIC_API_BASE | callbot | the api URL the browser resolves |
CORS_ALLOWED_ORIGINS | callbot | the studio's public origin, or the browser blocks sign-in |
TELCO_PROXY, TELCO_ALLOWED_NETS | gateway | the carrier SBC, and the networks it calls from |
PBX3CX_PROXY, PBX3CX_ALLOWED_NETS | gateway | your 3CX |
EXTERNAL_IP | gateway | the address put in SIP/SDP — set it explicitly behind 1:1 NAT |
RTP_START, RTP_END | gateway | the audio ports, which must be open inbound from the carrier |
AI_PROVIDER | callbot | simulator (bundled, no credentials) or a real provider |
Credentials
Every one is a Docker Swarm secret, created by secrets-init.sh and mounted into the container as a file. The services read FOO_FILE and resolve it themselves, so nothing sensitive passes through the environment and docker inspect stays clean.
| Secret | Held by | What it is |
|---|---|---|
plxy_postgres_password | callbot | schema-owner password (migrations run as owner) |
plxy_app_db_password | callbot | least-privilege runtime role — this is what enforces row-level security |
plxy_jwt_secret | callbot | signing key for studio logins |
plxy_apiext_secret_key | callbot | encrypts secret header values stored by API nodes |
plxy_azure_speech_key | callbot | created with a placeholder; supply the real key only if you use Azure |
plxy_esl_password | gateway | FreeSWITCH Event Socket, bound to 127.0.0.1 |
plxy_internal_api_token | both | service-to-service bearer |
plxy_vgw_control_token | both | bearer for the gateway's REST API |
plxy_vgw_worker_token | both | bearer on the gateway → worker session WebSocket |
The last three must be byte-identical in both clusters. That is what shared-tokens.<env>.env is for, and mismatched values are the cause of an otherwise inexplicable 401 on every cross-stack call.
Supplying a real Azure key after the fact means rotating that secret:
bash
AZURE_SPEECH_KEY=... deploy/swarm/secrets-init.sh callbot prod --rotateA Swarm secret in use cannot be replaced in place, so remove the stack first and redeploy after.
4. First sign-in and your first scenario
Open the studio at the origin you set in CORS_ALLOWED_ORIGINS. A fresh database seeds two administrator accounts, both ChangeMe123!:
| Login | Use it for |
|---|---|
ops@plxy.local | Everyday work — scenarios, campaigns, calls, monitoring, users |
admin@plxy.local | The same, plus tenants, service accounts and the cross-tenant audit log |
Change both passwords immediately. Both are full administrators — two doors into the same authority, kept apart so a fresh install has a spare. Treat them as break-glass credentials, then build the team you want in Admin → User Roles, where the narrower STUDIO_PROJECT and STUDIO_MONITORING roles are what most people should hold. See Roles & Access.
Then:
- Scenarios → build a flow in the visual editor; Validate, then Publish.
- Admin → Inbound routes → map a dialled number to that published scenario.
- Campaigns → create a campaign against a published scenario, upload a contacts CSV (
phonecolumn plus variable columns), press Start — the dialer originates calls through the gateway and results appear live in the campaign stats. - Calls → history with per-call event timelines.
An inbound call on the trunk now flows: FreeSWITCH → park → gateway → worker (scenario) → menu → transfer to a 3CX extension or hangup, with the result persisted.
5. Carrier and 3CX onboarding
| Step | What to do |
|---|---|
| Carrier trunk | Put the SBC addresses in TELCO_ALLOWED_NETS and TELCO_PROXY. Set TELCO_REGISTER=true with TELCO_USERNAME only if your contract issues SIP credentials; IP-authenticated trunks leave it false |
| Media path | Set EXTERNAL_IP to the public IPv4 if the host is behind 1:1 NAT, and open RTP_START–RTP_END/udp inbound from the carrier |
| 3CX trunk | Create a SIP trunk in 3CX pointing at this deployment, and set PBX3CX_PROXY to the 3CX host |
| Transfers | A scenario's TRANSFER node destination is the 3CX extension or queue number |
TELCO_ALLOWED_NETS is required and the media container refuses to start without it. The trunk profile does not digest-authenticate callers, so that ACL is the only thing between the SIP port and anything that can reach it — list the carrier's networks and nothing wider.
TLS/SRTP hardening on both legs, carrier certification and 3CX provisioning are steps between you and your providers. This deployment documents what it needs; it cannot complete them for you.
6. Staging
Staging is production with smaller numbers and a test trunk. Identical commands with staging in place of prod:
bash
cp deploy/swarm/env/callbot.staging.env.example deploy/swarm/env/callbot.staging.env
$EDITOR deploy/swarm/env/callbot.staging.env
deploy/swarm/secrets-init.sh callbot staging
deploy/swarm/deploy.sh callbot stagingWhat is the same, deliberately: the images, the stack files, and secrets generated the same way rather than fixed values. A staging environment that cuts those corners rehearses something other than what production will do.
What differs:
| Staging | Production | |
|---|---|---|
| Hosts | Often one | Two — the telephony half in the DMZ |
| Worker replicas | 1 | 2 or more |
| RTP range | Narrow (≈10 concurrent calls) | Sized for your traffic |
| Trunk | The carrier's test trunk, or a softphone registered to 3CX | The live trunk |
| AI provider | simulator unless you are testing a provider | Whatever you contracted |
Its secrets are its own: shared-tokens.staging.env is a separate file from production's, and the two environments never share a token.
On a single-node staging Swarm, deploy.sh applies the required node label itself — there is only one node, so there is nothing to choose wrongly.
Rehearse the exact tag you intend to promote. Staging on latest rehearses whatever was pushed last, which is not the release you are about to run.
7. Operating
Rolling updates
Updates are gated on the images' healthchecks and roll back automatically on failure (monitor: 60s, failure_action: rollback). Three services deliberately update stop-first: callbot-api (owns the migration lock and the single dialer loop), and both gateway services (fixed host ports cannot be double-bound). callbot-worker and callbot-ui update start-first, with no downtime.
To move to a new release, change TAG in both environment files and redeploy both stacks — or override it for one run:
bash
TAG=0.2.0 deploy/swarm/deploy.sh callbot prodRotating a shared token
Recreate the secret in both clusters and redeploy both stacks. A Swarm secret in use cannot be replaced in place, so remove the stack first, then:
bash
deploy/swarm/secrets-init.sh <stack> prod --rotateScaling
callbot-worker and callbot-ui scale freely (WORKER_REPLICAS, UI_REPLICAS).
callbot-api is pinned to one replica and cannot be scaled by changing a number: it owns the migration lock and the campaign dialer loop, and its rate limiter and idempotency store are in-process. Running two without REDIS_ENABLED=true admits twice the rate limit and stops deduplicating retries, which can place a duplicate outbound call.
Both gateway services are one replica each, for the host-port and single-ESL-consumer reasons in §2.
8. Troubleshooting
| Symptom | Cause / fix |
|---|---|
swarm task stuck in pending forever | an unsatisfiable placement constraint or a missing secret. deploy.sh preflights both; check docker service ps <svc> --no-trunc |
| every cross-stack call returns 401 | the three shared tokens differ between the two Swarms. Copy shared-tokens.<env>.env across and recreate the secrets on the second cluster |
| worker/vgw 401s between services | the same cause, one token at a time — they must match pairwise across the boundary |
| api exits at startup: JWT secret required | plxy_jwt_secret missing or shorter than 32 bytes. Recreate it with secrets-init.sh |
studio login fails with Failed to fetch and the api logs nothing | the browser blocked it as cross-origin before sending. Add the studio's origin to CORS_ALLOWED_ORIGINS (§3); the api's log is silent because the request never arrived |
| studio shows a login loop | PUBLIC_API_BASE must be reachable from the browser, not just from the container |
relation "users" does not exist | the api has not finished its migrations — check docker service logs plxy-callbot_callbot-api |
| postgres exits 1: "in 18+, these Docker images are configured to store database data…" | the volume is mounted at /var/lib/postgresql/data. From 18 the image keeps data in /var/lib/postgresql/18/docker and refuses the old path. The stacks already mount /var/lib/postgresql; a volume created before that change needs a dump and restore |
vgw /v1/health/fs reports connected:false | the media container is not up yet, or plxy_esl_password differs between the two gateway services |
| calls connect but there is no audio | the RTP range must be reachable inbound from the carrier, and EXTERNAL_IP must be the public IPv4 when the host is NATed. This is the most common deployment fault |
| inbound calls rejected | the caller's IP is not in TELCO_ALLOWED_NETS |
| transfers fail | PBX3CX_PROXY wrong, or the 3CX trunk does not accept calls from this host's IP |
container exits exec /app/entrypoint.sh: no such file or directory | CRLF line endings from a Windows clone — re-clone, or git add --renormalize . |
plxy.sh "is not recognized" on Windows | it is a bash script: run it from Git Bash, or bash plxy.sh <command> from PowerShell |
Reading the logs
bash
docker service logs plxy-callbot_callbot-api --tail 200 -f
docker service logs plxy-gateway_vgw-app --tail 200 -f
docker stack services plxy-callbotStartup is health-gated rather than ordered by hand, so a service that never becomes healthy surfaces as a stuck dependency rather than a cascade of connection-refused retries — the first unhealthy service in the chain is the one to read.