Self-hosting
Deploying
One way to get a production deployment running: build the image, run the API and worker against Postgres, apply migrations, create your first tenant, and put HTTPS in front. There are recipes for Docker Compose and for systemd.
1. Build the image
The repository includes a Dockerfile. The same image runs both the API
and the worker; only the command differs. It also installs two system libraries that
the WebRTC dependency loads at startup, so build from this file rather than a bare
Python image.
FROM python:3.13-slim
# libgl1/libglib2.0-0: opencv-python (pulled in transitively via aiortc, used
# by pipecat's WebRTC transport) needs libGL.so.1 at import time.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
COPY run.py ./
COPY alembic.ini ./
COPY alembic ./alembic
COPY app ./app
RUN pip install --no-cache-dir -e .
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Runs the API by default; the worker overrides the command.
CMD ["python", "run.py"] 2. Run the stack
A minimal Compose file with Postgres, the API and one worker. Put your production
settings in .env (see Configuration);
both services read it.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: voice
POSTGRES_PASSWORD: change-me-to-a-strong-password
POSTGRES_DB: voice
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U voice"]
interval: 5s
timeout: 3s
retries: 10
api:
build: .
env_file: .env
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
worker:
build: .
env_file: .env
depends_on:
postgres:
condition: service_healthy
command: python -m app.worker
volumes:
pgdata: 3. Apply migrations
The app doesn't migrate the database when it starts. Run migrations yourself on the first deploy and on every upgrade, before the new version takes traffic:
docker compose run --rm api alembic upgrade head 4. Create the first tenant
You need an API key to create anything through the API, so the first tenant and key come from the admin command line:
docker compose run --rm api python -m app.admin create-tenant \
--name "Acme" --slug acme
# prints an API key, shown once: store it in your secrets manager
Then add connections and
agents through the API. (seed-dev also exists,
but it copies provider keys from the environment and is meant for local development.)
5. Put HTTPS in front
Terminate TLS in a reverse proxy and forward to the API. The call audio arrives over a
WebSocket, so the proxy must allow connection upgrades on
/v1/telephony/stream. Set PUBLIC_BASE_URL to this public
HTTPS address; carriers fetch the answer webhook from it.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name api.your-domain.com;
# ssl_certificate / ssl_certificate_key ...
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket upgrade for the carrier audio stream
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
} Without Docker: systemd
Install the package into a virtualenv and run two services from it.
[Unit]
Description=Offhook API
After=network.target
[Service]
WorkingDirectory=/opt/offhook
EnvironmentFile=/opt/offhook/.env
ExecStart=/opt/offhook/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
User=offhook
[Install]
WantedBy=multi-user.target[Unit]
Description=Offhook dial/webhook worker
After=network.target
[Service]
WorkingDirectory=/opt/offhook
EnvironmentFile=/opt/offhook/.env
ExecStart=/opt/offhook/.venv/bin/python -m app.worker
# The worker finishes in-flight jobs on SIGTERM; give it time.
TimeoutStopSec=45
Restart=always
User=offhook
[Install]
WantedBy=multi-user.target Scaling and upgrades
- API: keeps no state, so run more instances behind the load balancer, or more uvicorn processes with
--workers N. - Worker: run several for more throughput. Each job is claimed by exactly one worker (
FOR UPDATE SKIP LOCKED), so nothing is dialed twice. Failed dials are retried with increasing delays, and a circuit breaker stops hammering a carrier that is down. - Shutdown: on
SIGTERM, workers finish what they're doing before exiting. Allow 30 to 45 seconds beforeSIGKILL; the systemd unit above does. - Upgrades: run
alembic upgrade headonce, then restart the API and the workers. Keep migrations backward-compatible if you want to upgrade without downtime.
Health checks
Point your load balancer and orchestrator at these:
curl https://api.your-domain.com/healthz
# {"status":"ok"} the process is up
curl https://api.your-domain.com/readyz
# {"status":"ready","checks":{ ready for traffic
# "database":"ok", the database is reachable
# "migrations":"ok", the schema is up to date
# "master_key":"ok"}} the master key works Next: Configuration, for the required settings, the master key, AWS KMS and row-level security.