Skip to content

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.

RequirementCheck
Docker Engine + Compose v2docker 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:

FromWhat you need
Your TelcoThe SBC addresses to allow, and whether the trunk is IP-authenticated or registered
Your 3CXA SIP trunk pointing at this deployment, and the extension or queue numbers transfers should reach
Your networkThe 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_AICC

Nothing 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:

StackFileServicesRuns on
callbotdeploy/swarm/docker-compose-callbot.ymlcallbot-api, callbot-worker, callbot-ui, postgres-dbinternal cluster
gatewaydeploy/swarm/docker-compose-gateway.ymlvgw-app, vgw-mediaDMZ 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
DirectionPortPurposeSet in
callbot → DMZ8080/tcporiginate calls, upload rendered TTSVGW_BASE_URL (callbot env)
DMZ → callbot29192/tcpworker session WebSocket, one per callCALLBOT_WORKER_WS_URL (gateway env)
DMZ → callbot29092/tcpreport finished call resultsCALLBOT_API_URL (gateway env)
carrier → DMZ5060/udp+tcp, 16384-16484/udpSIP and RTPcarrier + 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 --push

Then 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 prod

secrets-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 prod

Both 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-trunc

A 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:

VariableStackPurpose
REGISTRY, TAGbothwhich images, at which version — the same tag in both stacks
VGW_BASE_URLcallbotthe DMZ gateway, as reached from the internal cluster
CALLBOT_API_URL, CALLBOT_WORKER_WS_URLgatewaythe internal cluster, as reached from the DMZ
PUBLIC_API_BASEcallbotthe api URL the browser resolves
CORS_ALLOWED_ORIGINScallbotthe studio's public origin, or the browser blocks sign-in
TELCO_PROXY, TELCO_ALLOWED_NETSgatewaythe carrier SBC, and the networks it calls from
PBX3CX_PROXY, PBX3CX_ALLOWED_NETSgatewayyour 3CX
EXTERNAL_IPgatewaythe address put in SIP/SDP — set it explicitly behind 1:1 NAT
RTP_START, RTP_ENDgatewaythe audio ports, which must be open inbound from the carrier
AI_PROVIDERcallbotsimulator (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.

SecretHeld byWhat it is
plxy_postgres_passwordcallbotschema-owner password (migrations run as owner)
plxy_app_db_passwordcallbotleast-privilege runtime role — this is what enforces row-level security
plxy_jwt_secretcallbotsigning key for studio logins
plxy_apiext_secret_keycallbotencrypts secret header values stored by API nodes
plxy_azure_speech_keycallbotcreated with a placeholder; supply the real key only if you use Azure
plxy_esl_passwordgatewayFreeSWITCH Event Socket, bound to 127.0.0.1
plxy_internal_api_tokenbothservice-to-service bearer
plxy_vgw_control_tokenbothbearer for the gateway's REST API
plxy_vgw_worker_tokenbothbearer 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 --rotate

A 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!:

LoginUse it for
ops@plxy.localEveryday work — scenarios, campaigns, calls, monitoring, users
admin@plxy.localThe 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 (phone column 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

StepWhat to do
Carrier trunkPut 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 pathSet EXTERNAL_IP to the public IPv4 if the host is behind 1:1 NAT, and open RTP_STARTRTP_END/udp inbound from the carrier
3CX trunkCreate a SIP trunk in 3CX pointing at this deployment, and set PBX3CX_PROXY to the 3CX host
TransfersA 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 staging

What 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:

StagingProduction
HostsOften oneTwo — the telephony half in the DMZ
Worker replicas12 or more
RTP rangeNarrow (≈10 concurrent calls)Sized for your traffic
TrunkThe carrier's test trunk, or a softphone registered to 3CXThe live trunk
AI providersimulator unless you are testing a providerWhatever 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 prod

Rotating 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 --rotate

Scaling

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

SymptomCause / fix
swarm task stuck in pending foreveran unsatisfiable placement constraint or a missing secret. deploy.sh preflights both; check docker service ps <svc> --no-trunc
every cross-stack call returns 401the 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 servicesthe same cause, one token at a time — they must match pairwise across the boundary
api exits at startup: JWT secret requiredplxy_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 nothingthe 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 loopPUBLIC_API_BASE must be reachable from the browser, not just from the container
relation "users" does not existthe 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:falsethe media container is not up yet, or plxy_esl_password differs between the two gateway services
calls connect but there is no audiothe 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 rejectedthe caller's IP is not in TELCO_ALLOWED_NETS
transfers failPBX3CX_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 directoryCRLF line endings from a Windows clone — re-clone, or git add --renormalize .
plxy.sh "is not recognized" on Windowsit 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-callbot

Startup 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.

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