Self-hosting
Configuration
Offhook is configured with environment variables, checked once at startup so a bad value fails immediately rather than on the first call. Set them on both the API and the worker. Provider keys don't go here; they live encrypted in the database as connections.
Core
Environment
| Field | Type | Description |
|---|---|---|
ENVoptional | dev | prod | dev (the default) or prod. prod turns on the startup checks listed at the end of this page. |
DATABASE_URLrequired | string | The Postgres connection string, e.g. postgresql+asyncpg://voice:…@host:5432/voice. It must use the +asyncpg driver. |
PUBLIC_BASE_URLoptional | url | The public HTTPS address carriers call back on, for the answer webhook and the audio stream. Required in prod. |
LOG_LEVELoptional | string | Defaults to DEBUG. Use INFO or WARNING in production. |
RECORDINGS_DIRoptional | string | Where call recordings are saved on local disk (default recordings). On hosts that wipe the disk on redeploy, mount a volume here. |
Encryption and keys
Provider keys are protected with envelope encryption. Each tenant has its own data key, which encrypts that tenant's secrets, and a master key encrypts the data keys. The master key never goes in the database.
Keys
| Field | Type | Description |
|---|---|---|
MASTER_ENCRYPTION_KEYoptional | base64 | The master key: base64 of exactly 32 random bytes. Required in prod unless you use AWS KMS. If you lose it, every stored provider key is unrecoverable. |
MASTER_KEY_IDoptional | string | A name for the current master key (default v1), recorded with each data key so older keys can still be used during rotation. |
KEY_PROVIDERoptional | env | kms | env (the default) uses MASTER_ENCRYPTION_KEY. kms encrypts the data keys with AWS KMS instead. |
KMS_KEY_IDoptional | string | The KMS key ID or ARN. Required when KEY_PROVIDER=kms. |
To generate a master key:
python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())" Row-level security (optional)
Every query is already limited to one tenant in the application code. Postgres row-level security adds a second layer, so a missed filter still can't return another tenant's data. It's off by default because it needs two database roles.
RLS
| Field | Type | Description |
|---|---|---|
ENABLE_RLSoptional | bool | Turns on row-level security per transaction. Apply app/db/rls.sql first. |
The API connects as a role that row-level security applies to. The worker and the
carrier endpoints connect as a role that bypasses it, because they look calls up by
job or token before they know the tenant. app/db/rls.sql explains the
full setup.
Dashboard sign-in and CORS
Only needed if you host the optional dashboard.
Dashboard
| Field | Type | Description |
|---|---|---|
CLERK_ISSUERoptional | url | The issuer of your Clerk app's session tokens, e.g. https://your-app.clerk.accounts.dev. The API checks tokens against its published keys. |
CLERK_JWKS_URLoptional | url | Overrides where those keys are fetched from. By default it's derived from the issuer. |
CORS_ORIGINSoptional | comma-separated | Browser origins allowed to call the API. Set it to your dashboard's address in production; the default only allows the local development ports. |
Development settings
These are for local development. In prod, the first two must be off or the app won't start.
Development
| Field | Type | Description |
|---|---|---|
ENABLE_PREBUILT_UIoptional | bool | Serves the browser playground, which starts calls without authentication. On by default; must be false in prod. |
WEBHOOK_ALLOW_PRIVATEoptional | bool | Allows webhooks to private and loopback addresses, for testing a local receiver. It turns off a security check, so it must be false in prod. |
DEV_AGENT_IDoptional | uuid | The agent the playground talks to. |
Production checklist
With ENV=prod, the app refuses to start unless the master key (or KMS) and PUBLIC_BASE_URL are set and both development settings are off:
ENV=prod
DATABASE_URL=postgresql+asyncpg://voice_api:…@db:5432/voice
PUBLIC_BASE_URL=https://api.your-domain.com # required in prod
MASTER_ENCRYPTION_KEY=… # required in prod (or KEY_PROVIDER=kms)
ENABLE_PREBUILT_UI=false # must be false in prod
WEBHOOK_ALLOW_PRIVATE=false # must be false in prod
LOG_LEVEL=INFO
CORS_ORIGINS=https://console.your-domain.com Before you go live, also check that:
- The master key is in a secrets manager or KMS, not on the server and not in git.
- HTTPS is in front of the API, and
PUBLIC_BASE_URLmatches its address. - Migrations are applied.
/readyzreports"migrations": "ok". - Postgres is backed up, and you have tested a restore. The database holds every encrypted key and your whole call history.
CORS_ORIGINSlists only your real dashboard address.- At least one worker is running, with time to shut down gracefully. Without a worker, calls stay
queued. - Optionally,
ENABLE_RLS=truewith the two-role setup.
Day-to-day operations and troubleshooting are covered in RUNNING.md in
the repository.