Skip to main content
The chart in turncall-charts (charts/turncall; commands below run from a checkout of it) runs TurnCall against a Postgres (with pgvector) and a Redis you already have — or, for a laptop, brings its own — and, if you want it, the builder beside it. It is sized for the one thing a voice platform cannot get wrong on Kubernetes: a pod that is scaled down or replaced keeps its calls until they end. See Graceful shutdown for how a process drains. There are two ways to install it — the same chart with different values: The production path assumes you run the infrastructure and adds TurnCall to it; the development path is the docker compose stack on Kubernetes — convenient, with no backups or replication.

What it runs

The workers are the same image with another entrypoint and never run inside the API: their work in the API’s event loop would become dead air on a live call. Migrations (alembic upgrade head) run as a Job before any pod of a new version starts, on install and on every upgrade — or, with the bundled Postgres, in the API pod (see below).

Install (production)

TurnCall against your own Postgres, Redis and S3, with secrets in a Secret you manage.
values-production.yaml is the same install as a file to copy: a pinned image, existingSecret, S3, publicBaseUrl, and an Ingress with TLS and the WebSocket timeouts — plus the builder beside it; set builder.enabled=false for TurnCall alone. Replace its example.com hosts and placeholders. existingSecret holds every secret setting as an environment variable; config holds the rest. Both take the variables listed in the project’s environment reference. secretEnv is the alternative to existingSecret when you would rather have the chart own the Secret.
  • Pin image.tag to a release. A release without the drain would drop calls on every scale-down this chart performs. Every merge to main also publishes ghcr.io/kobikis/turncall:main and :sha-<commit>; pin a sha- tag to run code newer than the last release.
  • Use S3 storage (STORAGE_BACKEND: s3, the chart’s default). local storage is one pod’s filesystem, so a recording written on one pod is missing on the next.
  • Expose the API with ingress.enabled or your own route to the <release> Service. The chart’s Ingress routes /v1, /webhooks and /ws only (ingress.paths): /metrics, /ready, /live and /health carry no credential and report how many calls a pod is carrying, so they stay inside the cluster, where the kubelet and the PodMonitor reach each pod directly. A route of your own should leave them out the same way. Media streams are WebSockets that last a whole call, so raise your ingress controller’s proxy timeouts to at least your longest call (the annotations for ingress-nginx are in values.yaml).

Everything in the cluster (development)

values-local.yaml is the docker compose stack on Kubernetes (Docker Desktop, kind, minikube): the chart also runs Postgres (pgvector/pgvector:pg16) and Redis (redis:7-alpine), one pod and one volume each, keeps files on a volume (STORAGE_BACKEND: local), and fills DATABASE_URL and REDIS_URL itself. It also runs the builder; add --set builder.enabled=false for TurnCall alone. It runs local image tags, which it expects to find in your Docker — the released TurnCall image is linux/amd64 only. Build them from the code repos, or point each image at your registry’s:
Phone calls need a public https URL that reaches the API, so start the tunnel first and copy the URL it prints. On a free ngrok account it is a fixed domain, such as https://<name>.ngrok-free.dev, so it survives a restart:
Then the keys file, with that URL as publicBaseUrl:
The API is then on localhost:8090 — and at the ngrok URL, which forwards to the port-forward — with the dev PLATFORM_API_KEY from values-local.yaml for creating a project. publicBaseUrl becomes TurnCall’s PUBLIC_BASE_URL (config.PUBLIC_BASE_URL wins if both are set) and the URL the builder’s console shows as a number’s webhook. Without it, a number bound is saved but not configured at the carrier, and the console says so. If the URL changes, or you installed without it:
Then re-save each number in the console, so TurnCall points it at the new URL.
  • Migrations run in the API pod’s migrate init container, retrying until Postgres is up: the hook Job runs before install creates anything, so it cannot migrate a database the same install brings. The workers wait in a wait-for-migrations init container until the database is at the image’s head revision, so they start against the tables they expect — on upgrades too.
  • One API pod. The files volume is ReadWriteOnce.
  • A rebuilt image under the same tag is picked up by kubectl rollout restart deploy: values-local.yaml sets pullPolicy: Always, and Docker Desktop’s cluster pulls local tags from the Docker engine. On kind or minikube, where images are loaded onto the nodes, set image.pullPolicy=IfNotPresent.
  • Volumes outlive helm uninstall — Postgres’s and Redis’s (a StatefulSet’s claims), the files one and the builder’s generated repos. kubectl delete namespace turncall starts over.
  • After pulling a newer chart, upgrade with --reset-then-reuse-values: the chart’s new defaults plus the values you installed with. --reuse-values alone keeps the old defaults too.
  • Not for production: no replication, no backups, dev secrets. Each piece is a switch (postgresql.enabled, redis.enabled, localStorage.enabled), so moving to managed services is turning them off and setting the URLs.

The builder

builder.enabled adds the agent builder — builder-api and its console — to the same release. Off by default: TurnCall installs alone.
  • It finds TurnCall itself: the builder talks to this release’s TurnCall Service and reads TurnCall’s PLATFORM_API_KEY from TurnCall’s Secret, so the two can never disagree. publicBaseUrl is also what the console shows as a number’s webhook.
  • Its database is builder on the bundled Postgres, created on first start, or builder.secretEnv.DATABASE_URL (or builder.existingSecret) for your own.
  • Each agent’s backend — its tool webhooks, call-init and event feed — runs as a Deployment named for the agent and a Service, which builder-api creates through the Kubernetes API, holding a Role over just those kinds of object. All of them run one runtime image (builder.agentRuntime.image) with the agent’s code mounted, so nothing is built in the cluster. TurnCall reaches them at http://turncall-agent-<port>.<namespace>.svc.cluster.local:8000, which is why a production WebRTC server_url may be an in-cluster Service.
  • Agent Backends’ namespace (builder.agentNamespace, default the release’s): in production, give them one of their own. They run code the builder generated, and builder-api’s Role — which covers Secrets — is then granted there instead of beside TurnCall’s Secret.
  • The console is the only part a browser needs: it serves the UI and proxies /api to builder-api, so Google login and Pipedream callbacks come back through it (https://<console>/api/...). Expose it with builder.ingress; a builder turn streams for minutes, so turn the controller’s buffering off and raise its read timeout.
  • Private images: imagePullSecrets reaches every pod, Agent Backends included.
On a laptop, log in with a dev account:
seed_guest refuses when builder.config.TURNCALL_ENV is production.

Grace periods

Kubernetes sends SIGKILL when a pod’s terminationGracePeriodSeconds runs out, and a killed pod drops its calls. The chart sizes each grace period from the work that process may still be holding: Set maxCallDurationSeconds to the longest max_call_duration_seconds among your agents (default 3600). It is the one value the chart cannot read for you. Lower it on agents that never need an hour and every scale-down gets shorter. The API pods also use:
  • a preStop sleep (api.preStopSeconds, 10), during which the terminating pod leaves the Service’s endpoints and load balancers catch up;
  • readiness on /ready, which fails from the moment the drain starts;
  • liveness and startup on /live, which stays green while draining;
  • maxUnavailable: 0 on rollouts, so a new pod is ready before an old one starts to drain.

Asterisk

Asterisk’s ARI hands an app’s calls to the newest connection only, and the connection it replaced never gets them back. Two processes holding it would split calls between a process that knows them and one that does not. So the chart never lets an API replica hold it: every API pod runs with ASTERISK_EDGE_ENABLED=false, and asteriskEdge.enabled: true adds one Deployment that holds it:
  • one replica, never autoscaled;
  • Recreate on rollout, so the old edge is gone before the new one connects;
  • not behind the Service. Each call’s media stream reaches the API pods through the websocket_client.conf URI you gave Asterisk.
Enable it if the platform or any project uses Asterisk. With it off, nothing connects to Asterisk. Restarting the edge has a cost, so change it in a quiet hour:
  • For the seconds between the old pod exiting and the new one connecting, Asterisk has no turncall app, and Stasis() returns with STASISSTATUS=FAILED. Route that to your fallback in the dialplan.
  • Calls already connected keep their agent, but the new edge does not know them. When one of those callers hangs up, nothing ends the agent’s side, which runs until its idle guard (or max_call_duration_seconds) ends it.

Autoscaling

Scale the API on live calls per pod, not CPU. A pod runs every call’s audio on one event loop, so its limit is set by audio underruns and turn latency, not by average CPU. The chart’s HorizontalPodAutoscaler targets an average of autoscaling.activeCallsPerPod across the API pods, read from the turncall_active_calls gauge on each pod’s /metrics. The chart’s default target is 2 calls per pod, with requests.cpu: 1, requests.memory: 1Gi and no CPU limit. That is the measured capacity for the default cascade agent: 2 concurrent calls with no audio gaps on one dedicated core, the first gaps at 4. It was measured on an Apple M1 Pro, in a Docker Desktop VM with 8 vCPU, on a busy host, so treat it as a floor and re-measure on your own node type before raising it.
  • Do not set a CPU limit. A CPU quota freezes the whole process for the rest of a 100 ms period whenever it bursts past it, and since every call’s audio runs on one event loop, each freeze is a gap on every call at once. Under limits.cpu: 1 a pod carried only 1 call cleanly; a second CPU of quota did not help, because the loop is single-threaded.
  • Memory is about 265 MiB once the first call has loaded Smart Turn, plus 45 MiB per call. The memory limit (2Gi) sits well above the request: reaching it is an OOM kill, which drops every call on the pod.
Scale-down is deliberately slow (one pod per 5 minutes, after 10 minutes of low load): a removed pod drains its calls before it goes, so quick scale-downs churn pods that are only draining. Kubernetes picks which pod to remove without knowing how many calls each carries.

With prometheus-adapter

The HPA reads turncall_active_calls from the custom metrics API. With Prometheus scraping each API pod (podMonitor.enabled for the Prometheus operator, or the prometheus.io/* annotations the pods carry), give prometheus-adapter this rule:
Then:
Scrape each pod, never the Service: the gauge is per process, and a load-balanced scrape reads a random pod’s count.

With KEDA

Leave autoscaling.enabled off and give KEDA the same target:
KEDA divides the query’s total by the threshold to get a replica count, which is the same arithmetic as the HPA’s average.

Not covered

  • WebRTC media is UDP between the browser and the pod, which an Ingress does not carry. Browser calls — the builder’s test calls included — need the pods reachable over UDP (or a TURN server), which is outside this chart. Phone calls are unaffected: their audio arrives over the carrier’s WebSocket.
  • Postgres and Redis in production are yours to run (the bundled ones are for development). Postgres needs the vector extension available; the first migration creates it.