# Production deployment

One Linux VM runs the realtime parts (FreeSWITCH, coturn, api, controller, Redis, Caddy, backups).
Managed services cover everything else:

| Part | Where |
|---|---|
| Postgres | **Supabase** (session pooler, schema `voice`) |
| Recordings + database backups | **Cloudflare R2** |
| DNS, TLS and DDoS protection for `api.` / `app.` | **Cloudflare** (proxied, Origin Certificate on the VM) |
| Dashboard | **Cloudflare Workers** via OpenNext (or a container on the VM) |
| SIP, RTP, WSS, TURN | The VM, DNS-only records: Cloudflare cannot proxy SIP/RTP/TURN |

```
browser ──https──> Cloudflare ──> app.  (Worker: dashboard) ──https──> api.
CRM backend ─https─> Cloudflare ──> api. ──> Caddy :443 ──> api :3000 (127.0.0.1)
browser softphone ──wss :7443 + DTLS-SRTP──> FreeSWITCH  (sip., DNS-only)
                  ──turn(s) :3478/:5349───> coturn       (turn., DNS-only)
carrier ──SIP :5080 + RTP 16384–32768──────> FreeSWITCH
FreeSWITCH ──xml_curl──> api (127.0.0.1)   controller ──ESL──> FreeSWITCH (127.0.0.1:8021)
api/controller ──> Supabase (TLS)   ──> R2 (HTTPS)   ──> Redis (127.0.0.1)
```

## 1. Prerequisites (what you provide)
- A VM: Ubuntu 24.04 LTS, 4 vCPU / 8 GB / 40 GB SSD for ~100 concurrent calls (see
  [load test report](load-test-report.md)), a **static public IPv4 on the interface** (Hetzner,
  OVH, DigitalOcean, Vultr… — 1:1 NAT clouds such as AWS also work, `PUBLIC_IP` covers that).
- A domain in Cloudflare and a Cloudflare API token with `Zone → DNS → Edit` for that zone
  (Let's Encrypt DNS-01 for `sip.` and `turn.`).
- A Supabase project (region close to the VM) and its database password.
- A Cloudflare R2 bucket plus an R2 API token (Object Read & Write, that bucket only).
- Carrier accounts and keys (Telnyx and/or Twilio, or a SIP trunk) — entered later in the dashboard.

## 2. DNS (Cloudflare)
| Record | Type | Value | Proxy |
|---|---|---|---|
| `api.example.com` | A | VM IP | **Proxied** (orange) |
| `app.example.com` | — | Workers custom domain (step 8) | Proxied |
| `sip.example.com` | A | VM IP | **DNS only** (grey) |
| `turn.example.com` | A | VM IP | **DNS only** (grey) |

Proxied hostnames must be at most one level below the zone (`api.example.com`, not
`api.voice.example.com`): Cloudflare's free Universal SSL certificate covers only `example.com` and
`*.example.com`. Deeper names need Advanced Certificate Manager (paid). DNS-only names (`sip.`, `turn.`)
use Let's Encrypt and can be at any depth. Example: the first deployment uses `tvh-voice.gigaleads.nl`
(dashboard) and `tvh-voice-api.gigaleads.nl` (API) for that reason.

SSL/TLS mode for the zone: **Full (strict)**. Create an Origin Certificate (SSL/TLS → Origin Server)
for `api.example.com` (and `app.` if the dashboard runs as a container) and save it as
`/etc/voice/certs/origin.pem` and `origin-key.pem`.

## 3. VM setup
```bash
apt update && apt install -y docker.io docker-compose-v2 fail2ban certbot python3-certbot-dns-cloudflare ufw
install -d -m 0750 /etc/voice/certs /var/log/freeswitch
git clone <your repo> /opt/voice && cd /opt/voice
```

Firewall (`ufw`). Everything not listed stays closed — in particular ESL (8021), the API's own port
(3000), the controller (3001), Redis (6379), Prometheus and Grafana, which all bind to 127.0.0.1 anyway.

```bash
ufw default deny incoming && ufw default allow outgoing
ufw allow 22/tcp                         # SSH (restrict to your IP if you can)
ufw allow 443/tcp                        # Caddy, behind Cloudflare
ufw allow 7443/tcp                       # WSS for browser softphones
ufw allow 5080/udp && ufw allow 5080/tcp # SIP from carriers (FreeSWITCH also checks its carrier ACL)
ufw allow 16384:32768/udp                # RTP
ufw allow 3478 && ufw allow 5349/tcp     # STUN/TURN (UDP+TCP) and TURNS
ufw allow 49160:49999/udp                # TURN relay ports
ufw enable
```
Port 5060 (the agents profile over UDP/TCP) is intentionally **not** opened: agents use WSS. Open it
only if you add desk phones, and then keep fail2ban on. Port 443 can be limited to
[Cloudflare's IP ranges](https://www.cloudflare.com/ips/) so nobody bypasses Cloudflare.

## 4. Certificates for `sip.` and `turn.` (Let's Encrypt, DNS-01)
```bash
printf 'dns_cloudflare_api_token = %s\n' '<token>' > /etc/voice/cloudflare.ini && chmod 600 /etc/voice/cloudflare.ini
install -m 755 /opt/voice/infra/certs/deploy-hook.sh /etc/letsencrypt/renewal-hooks/deploy/voice.sh
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/voice/cloudflare.ini \
  -d sip.example.com -d turn.example.com --deploy-hook /etc/letsencrypt/renewal-hooks/deploy/voice.sh
```
The hook writes `/etc/voice/certs/wss.pem` (FreeSWITCH WSS + DTLS) and `turn-*.pem` (coturn) and
restarts both. A renewal restart drops live calls; move certbot's timer to a quiet hour:
`systemctl edit certbot.timer` → `OnCalendar=*-*-* 04:15`. (The first run happens before the stack
exists; the restart step then fails harmlessly.)

## 5. Supabase
1. Connect → **Session pooler** connection string (port 5432; reachable over IPv4, unlike the direct
   connection on some plans). The transaction pooler (6543) has not been tested; don't use it.
   The pooler host differs per project (`aws-0-…` or `aws-1-…`); the wrong one answers
   `tenant/user … not found`.
2. Copy Supabase's root CA (`infra/certs/supabase-ca.crt`, "Supabase Root 2021 CA", valid until 2031;
   the same file as Database settings → **SSL certificate**) to `/etc/voice/certs/supabase-ca.crt`.
3. Use a dedicated login role instead of `postgres` (SQL editor):
   ```sql
   create role voice_app with login password '<openssl rand -hex 24>' noinherit;
   grant create, connect, temporary on database postgres to voice_app;
   ```
   The role creates and owns the `voice` schema on the first migration; Supabase's API roles get no
   access to it. The pooler user name is `voice_app.<project ref>`.
4. `DATABASE_URL=postgresql://voice_app.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres?sslmode=verify-full&sslrootcert=/certs/supabase-ca.crt`
   (node-postgres and `pg_dump` both read these parameters). Verified against a real project:
   the certificate check passes with this CA.
5. Migrations run automatically when the API starts (`MIGRATE_ON_START=true`) and only touch the
   `voice` schema. Supabase's API layer is not used; do not expose the `voice` schema in its API settings.

## 6. Configure and start
```bash
cp .env.production.example .env.production && chmod 600 .env.production
# fill in; generate secrets with: openssl rand -base64 32
# ENCRYPTION_KEYS: k1:$(openssl rand -base64 32)
docker compose -f docker-compose.prod.yml --env-file .env.production build
docker compose -f docker-compose.prod.yml --env-file .env.production up -d
docker compose -f docker-compose.prod.yml --env-file .env.production --profile ops up -d   # metrics (optional)
```
Rules for production values:
- `PROVIDER_API_OVERRIDES` must be **absent** (it is refused when `NODE_ENV=production`).
- `ALLOW_MOCK_CARRIER=false` (set by the compose file).
- Every secret is different and at least 32 random bytes. Keep a copy of `ENCRYPTION_KEYS` in a password
  manager: without it, stored carrier keys and webhook secrets cannot be decrypted (re-entering them is the only fix).

Create the first admin and the CRM's API key (the password is read from the environment, never from argv):
```bash
alias vp='docker compose -f /opt/voice/docker-compose.prod.yml --env-file /opt/voice/.env.production'
read -rs ADMIN_PASSWORD && export ADMIN_PASSWORD
vp exec -T -e ADMIN_PASSWORD api node dist/cli.js admin:create --email you@example.com --name "Your name"
vp exec -T api node dist/cli.js api-key:create --name "CRM backend" --scopes admin
```

## 7. fail2ban
```bash
cp /opt/voice/infra/fail2ban/filter.d/*.conf /etc/fail2ban/filter.d/
cp /opt/voice/infra/fail2ban/jail.d/voice.conf /etc/fail2ban/jail.d/
systemctl restart fail2ban && fail2ban-client status freeswitch-voice
```
FreeSWITCH writes warnings to `/var/log/freeswitch/freeswitch.log` (bind-mounted). Rotate it with
logrotate (`copytruncate`, daily, keep 7).

## 8. Dashboard on Cloudflare Workers
```bash
cd apps/dashboard
# wrangler.jsonc: set vars.INTERNAL_API_URL to https://api.example.com
pnpm wrangler login
pnpm cf:deploy
```
Then Workers → `voice-dashboard` → Settings → Domains → add `app.example.com`. Set `DASHBOARD_URL`
in `.env.production` to `https://app.example.com` (the API only accepts dashboard sign-ins from that
origin) and restart the api. Container alternative: set `DASHBOARD_HOST`, add `app.` as a proxied A record,
and `vp --profile dashboard up -d`.

## 9. Verify
```bash
curl -fsS https://api.example.com/readyz                  # api + database + redis
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/internal/freeswitch/xml   # 404 (blocked by Caddy)
vp exec freeswitch sh -c 'fs_cli -p "$ESL_PASSWORD" -x "sofia status"'   # agents + carriers RUNNING
curl -fsS http://127.0.0.1:3001/readyz                    # controller connected to ESL
```
Then in the dashboard: connect the carrier (health turns green), import or order a number, create an
agent, open Test call → call your mobile. Inbound: call the number. Checklist: [security.md](security.md).

## 10. Updates
```bash
cd /opt/voice && git pull
vp build && vp up -d        # migrations run on api start; FreeSWITCH restarts only if its image changed
```
A FreeSWITCH restart drops live calls — deploy FreeSWITCH changes outside business hours
([operations.md](operations.md#freeswitch-restart)).
