welldun. offhook

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.

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

docker-compose.yml
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:

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

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

nginx
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

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 before SIGKILL; the systemd unit above does.
  • Upgrades: run alembic upgrade head once, 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:

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