Deployment
reachable, and safe.
Put a TLS reverse proxy in front of VOX, point your phone number's webhooks at it, run it as a service and back up its data. Nothing else faces the internet.
01What faces the internet
| Port | What | Exposed? |
|---|---|---|
| 8765 | Media server: calls, texts and provider webhooks | Only through the proxy, only the paths below |
| 8080 | SaaS web app: public site, /app/, /op/, /api/, /stripe/webhook | Only through the proxy (SaaS mode) |
| 8766 | Single-user console | Never. SSH tunnel only: ssh -L 8766:127.0.0.1:8766 your-server |
Behind a proxy run the media server on loopback: --serve-host 127.0.0.1. Leave it on 0.0.0.0 only when a firewall closes port 8765.
02Reverse proxy
The media server speaks plain HTTP. TLS comes from the proxy. Paths must reach VOX unchanged: Twilio signs over https://<VOX_PUBLIC_HOST><path>.
| Path | To | Called by |
|---|---|---|
/ws (WebSocket) | 8765 | Twilio media stream of a call |
/voice, /voice/pin | 8765 | inbound call, PIN entry (Twilio) |
/sms, /sms/status | 8765 | inbound SMS/WhatsApp, delivery status (Twilio) |
/call/status | 8765 | Twilio call status callback (billing of minutes in SaaS) |
/ws/telnyx, /call/telnyx, /sms/telnyx | 8765 | Telnyx media stream, call events, texts |
/telegram | 8765 | Telegram commands (only with the Telegram channel) |
| everything else | 8080 (SaaS) · 404 (single-user) | browsers, Stripe |
Ready single-user files: deploy/Caddyfile.example (automatic HTTPS) and deploy/nginx-vox.conf.example. They forward only the media routes and answer 404 to everything else; both were validated locally (caddy validate, nginx -t, WebSocket upgrade, 403 on unsigned webhooks).
SaaS mode with Caddy
vox.example.com {
@media path /ws /ws/telnyx /sms /sms/status /sms/telnyx /voice /voice/pin /call/status /call/telnyx /telegram
handle @media {
request_body {
max_size 64KB
}
reverse_proxy 127.0.0.1:8765
}
handle {
request_body {
max_size 2MB
}
reverse_proxy 127.0.0.1:8080
}
}
SaaS mode with nginx
map $http_upgrade $vox_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name vox.example.com;
ssl_certificate /etc/letsencrypt/live/vox.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vox.example.com/privkey.pem;
server_tokens off;
location = /ws {
proxy_pass http://127.0.0.1:8765;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $vox_connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location = /ws/telnyx {
access_log off; # the URL carries the call's one-time secret
proxy_pass http://127.0.0.1:8765;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $vox_connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location ~ ^/(sms|sms/status|sms/telnyx|voice|voice/pin|call/status|call/telnyx|telegram)$ {
client_max_body_size 64k;
proxy_pass http://127.0.0.1:8765;
proxy_set_header Host $host;
}
location / {
client_max_body_size 2m;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}
Plus the usual port-80 server for the ACME challenge and the redirect to HTTPS, as in deploy/nginx-vox.conf.example. The 2 MB limit covers a logo upload (≤ 512 KB) and Stripe webhooks (≤ 1 MB).
The SaaS proxy blocks are derived from the routes in the code and the validated single-user examples. The SaaS variants themselves have not been run.
The web app sets a strict Content-Security-Policy, HSTS (unless --insecure-http) and no-store on API answers by itself. It serves no third-party scripts, fonts or trackers.
03The Twilio number
Twilio Console → Phone Numbers → your number:
| Setting | Value | Method |
|---|---|---|
| Voice — "A call comes in" | https://<host>/voice | HTTP POST |
| Voice — "Call status changes" | https://<host>/call/status | HTTP POST |
| Messaging — "A message comes in" | https://<host>/sms | HTTP POST |
/call/statusis required for billing inbound minutes in SaaS mode. Calls VOX places attach it automatically./sms/statusand/voice/pinare set by VOX per message and per call; nothing to configure.- Every webhook must carry a valid
X-Twilio-Signatureforhttps://VOX_PUBLIC_HOST<path>and your Account SID; anything else gets 403. - In SaaS mode one number serves all customers. An inbound call or SMS belongs to the customer who verified the sending number (a number can be verified by one customer only:
409 NUMBER_IN_USE). - Trial Twilio accounts call only verified numbers; the destination country must be enabled in Voice geographic permissions.
Telnyx setup: Configuration → Telnyx.
04One machine, one data directory
dial writes the call plan to app/data/calls/plans/, and the media server finds it there when the stream connects. Both must run over the same data directory. The server hangs up without a word on a stream it did not place (for example a call started from the Twilio console).
05Run it as a service
Docker restarts the container by itself. Without Docker use the systemd units in deploy/systemd/ (Installation). Stopping sends SIGINT; up and saas stop every service cleanly.
06Backups
app/data/ holds the profile, calls, the SaaS database (saas/vox.db), every customer and secrets.env. Protect a backup like the keys.
# Docker: archive the vox-data volume
docker run --rm -v mikodes-vox_vox-data:/d -v "$PWD":/b debian:bookworm-slim tar czf /b/vox-data.tgz -C /d .
docker compose down keeps the data. docker compose down -v deletes the volume with the profile, calls and keys, irreversibly.
07Before users: the first call
- Check
python -m voice checkexits 0. - Verify your own numberIn the console (how).
- Call yourself
python -m voice dial --purpose "test". Listen: voice quality, barge-in, the time until it answers. - Then a third-party testTo a second phone you own. The disclosure must play in full before anything else.
- Only thenopen sign-up or hand it to a user.