Deployment
The Backchannel app deploys as a self-hosted Docker Compose stack: nginx-served frontend, FastAPI backend, and PostgreSQL. Anything that runs Docker can host the app. The public documentation site is a separate Cloudflare Worker with static assets, private D1 access records, and private R2 desktop releases.
Services (docker-compose.yml)
Section titled “Services (docker-compose.yml)”| Service | Image/build | Ports | Notes |
|---|---|---|---|
db |
postgres:16-alpine |
5432:5432 |
Credentials from POSTGRES_* (defaults callhelper/changeme/callhelper); healthcheck gates backend start |
backend |
./backend Dockerfile |
8001:8000 |
Reads .env; DATABASE_URL is composed from the POSTGRES_* values; runs python scripts/start_backend.py |
frontend |
./frontend Dockerfile (Vite build + nginx) |
3000:80 |
Proxies /api and /ws to the backend |
Named volumes:
pgdata– PostgreSQL databackend_data– mounted at/app/data(DATA_DIR): recorded call audio, locally downloaded ASR model weights, and the credentials master key. Back this volume up if recordings matter to you; losingDATA_DIR/master.keymakes the stored provider credentials unreadable.
The backend service also bind-mounts ./backend/app into the container and
starts uvicorn with reload by default (BACKEND_RELOAD=true), so code edits
apply without rebuilding – a development convenience to disable for
production-like deployments.
Backend build arguments
Section titled “Backend build arguments”Set via environment variables consumed in docker-compose.yml:
| Variable | Default | Effect |
|---|---|---|
INSTALL_SORTFORMER |
true |
Install PyTorch/NeMo dependencies for the Sortformer diarizer at build time |
PYTORCH_INDEX_URL |
auto |
PyTorch wheel index. auto selects CUDA wheels only when an NVIDIA GPU is detected, and Docker builds never see a GPU, so images get CPU wheels unless you set this explicitly (e.g. cu130) |
ONNX_GPU |
false (set true by the GPU override) |
Install GPU ONNX Runtime |
Container sizing and call-start admission
Section titled “Container sizing and call-start admission”The call-start capacity check
(backend/app/services/capacity_admission.py) budgets the audio and model
stack from the container’s own limits: it reads the cgroup memory limit
(v2 memory.max, then v1 memory.limit_in_bytes), falls back to a
conservative 4096 MB when no limit is readable, and reserves 1.0 CPU core
for the event loop, WebSocket I/O, the database driver, and the OS. CPU and
memory limits on the backend container therefore directly shape the
call-start verdict: give it explicit, realistic limits so admission reasons
from real numbers rather than the fallback.
GPU deployment (NVIDIA, Docker)
Section titled “GPU deployment (NVIDIA, Docker)”The GPU overlay reserves NVIDIA GPUs for the backend container and enables GPU ONNX Runtime:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --buildValidate that Docker can see the GPU independently of the app:
docker run --rm --gpus all nvidia/cuda:13.0.0-base-ubuntu24.04 nvidia-smiThe GPU is used for diarization (Sortformer and faster embedding inference).
The Admin panel’s diarization capability check
(GET /api/diagnostics/diarization) reports whether CUDA is visible inside
the container.
AMD GPU on Windows (native backend)
Section titled “AMD GPU on Windows (native backend)”Docker cannot pass an AMD GPU through to Linux containers on Windows (WSL2
exposes AMD GPUs only via a DirectX bridge that the standard ROCm stack does
not use), so docker-compose up always runs Sortformer on CPU on an AMD
machine. To use an AMD GPU, run the backend natively on Windows with AMD’s
official PyTorch-on-Windows (ROCm) wheels.
Requirements:
- An RDNA4 (e.g. Radeon RX 9070 / 9070 XT) or other ROCm-on-Windows supported GPU – see AMD’s Windows compatibility matrix
- AMD Adrenalin driver 26.2.2 or newer
- Python 3.12 (AMD’s wheels are cp312-only):
winget install Python.Python.3.12
One-time setup, from the repo root in PowerShell:
.\backend\scripts\setup_windows_gpu.ps1The script creates backend/.venv on Python 3.12, installs backend
requirements, runs scripts/install_sortformer.py (which auto-detects the
AMD GPU and installs AMD’s ROCm torch wheels from repo.radeon.com instead of
CPU wheels), downloads the ONNX models, and prints whether torch can see the
GPU.
To run the hybrid stack (Postgres in Docker, backend native, frontend via the Vite dev server):
.\backend\scripts\setup_windows_gpu.ps1 -Run # starts db + backend on :8000cd frontend; npm run dev # separate terminalThe compose frontend container cannot reach a native backend (its nginx
proxies to the backend container by name), so use the Vite dev frontend –
its proxy already targets a local backend on port 8000.
Once running, ROCm torch builds report through the torch.cuda API, so the
Admin panel’s diarization card shows Device: CUDA with GPU accel:
ROCm (AMD). Run the Sortformer benchmark from that card to unlock Enhanced
mode.
Frontend proxying (frontend/nginx.conf)
Section titled “Frontend proxying (frontend/nginx.conf)”nginx serves the built SPA and proxies backend traffic so the browser only needs port 3000:
location /api/->http://backend:8000with 1800s read/send timeouts (long imports and re-transcription runs)location /ws/->http://backend:8000with HTTP/1.1 upgrade headers and an 86400s read timeout (all-day calls)client_max_body_size 250Mto allow large audio imports- SPA fallback:
try_files $uri $uri/ /index.html
If you front the stack with another proxy, replicate the WebSocket upgrade
headers and generous timeouts for /ws/.
Startup behavior
Section titled “Startup behavior”On startup the backend (backend/app/main.py):
- Runs
Base.metadata.create_all()so a fresh database works with no manual migration step - Runs
_add_missing_columns()to patch older local databases with columnscreate_allwill not add - Seeds agent configurations (
backend/app/services/seed_agents.py)
Alembic migrations exist under backend/alembic/ for tracked schema
history (alembic upgrade head), but the startup patching is part of
current runtime behavior – see Quickstart.
backend/scripts/start_backend.py is the container entrypoint; it launches
uvicorn (honoring BACKEND_RELOAD) after the database is reachable.
Data locations
Section titled “Data locations”| Data | Location |
|---|---|
| Recorded call audio | DATA_DIR/audio/<session_id>/segment_<n>.wav |
| Local ASR model weights | DATA_DIR/asr-models/ (downloaded on first use) |
| Credentials master key | DATA_DIR/master.key (unless CREDENTIALS_MASTER_KEY is set) |
| VAD / speaker-embedding models | backend/models/*.onnx (baked into the image / fetched by scripts/download_models.py) |
Desktop deployment
Section titled “Desktop deployment”The desktop bundle is the no-Docker deployment: a PyInstaller launcher runs
the same backend with an embedded per-user PostgreSQL and serves the built
frontend, keeping everything under a per-user data root
(%LOCALAPPDATA%\Backchannel on Windows,
~/Library/Application Support/Backchannel on macOS,
~/.local/share/backchannel on Linux). Update downloads and state live in
the data root’s updates/ directory; staged bundles sit next to the install
root so the final swap is a same-filesystem rename. BACKCHANNEL_DESKTOP=1
gates the desktop-only behavior: it enables TrustedHostMiddleware
(loopback hosts only) and activates the /api/updates routes, whose
mutating endpoints additionally require an X-Backchannel-Instance header
matching the launcher’s BACKCHANNEL_INSTANCE_TOKEN
(backend/app/routers/updates.py).
Cloudflare release-access deployment gate
Section titled “Cloudflare release-access deployment gate”The site Worker owns three separate boundaries: public interest capture, Cloudflare Access-protected administration, and public desktop delivery. Release listing and asset downloads are anonymous; recipient accounts remain only to authorize the deployed desktop updater’s grant flow. Run this production gate in order and stop on any failed check.
Admin identity and authorization migration cutover
Section titled “Admin identity and authorization migration cutover”The operator console has three strict ownership boundaries:
- Early access: request and consent review plus approve/reject only.
- Users: identity state, password reset, session sign-out, and revoke.
- Authorization: Latest and explicit-version grants only.
Authorization policy lives in release_access_policies plus
release_account_versions. The old /api/admin/access/* routes are removed
when the Worker and all admin assets deploy together.
Rehearse the guarded cutover in preview first: freeze approval, rejection,
password reset, session sign-out, revoke, and grant replacement; back up D1;
apply migration 0003_release_access_policies.sql; require both parity queries
below to return zero; deploy the Worker and all admin assets together; unfreeze
mutations; then smoke test. Recipient reads and downloads remain available
during the mutation freeze. Repeat the same guarded sequence in production.
1. Freeze mutations, then export and back up production D1
Section titled “1. Freeze mutations, then export and back up production D1”Record the production admin-mutation freeze before taking the backup. Do not approve or reject requests, reset passwords, sign out sessions, revoke users, or replace grants until step 6 explicitly ends the freeze. Recipient reads and downloads may continue.
From docs-site/, export the complete production database before applying any
migration:
cd docs-site$backupPath = $env:BACKCHANNEL_D1_BACKUP_PATHif ([string]::IsNullOrWhiteSpace($backupPath) -or -not [IO.Path]::IsPathRooted($backupPath)) { throw 'BACKCHANNEL_D1_BACKUP_PATH must be an absolute path in approved encrypted storage outside the repository.'}$backupPath = [IO.Path]::GetFullPath($backupPath)$repoRoot = (Resolve-Path ..).Pathif ($backupPath.StartsWith($repoRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'BACKCHANNEL_D1_BACKUP_PATH must be outside the repository.'}npx wrangler d1 export INTEREST_DB --remote --output="$backupPath"Set BACKCHANNEL_D1_BACKUP_PATH to an operator-controlled absolute path in
approved encrypted storage before running the command. The export contains
personal and authentication data; never create it in the repository first.
Restrict it to the minimum operators and retain it through deployment
acceptance.
2. Apply migration 0003 and prove integrity and policy parity
Section titled “2. Apply migration 0003 and prove integrity and policy parity”Migration 0002_release_access.sql remains the prerequisite release-account
schema. Exercise all pending migrations locally first:
npx wrangler d1 migrations apply INTEREST_DB --localnpx wrangler d1 execute INTEREST_DB --local --command "PRAGMA foreign_key_check; PRAGMA integrity_check;"Then, while the production mutation freeze remains active, apply and check production:
npx wrangler d1 migrations apply INTEREST_DB --remotenpx wrangler d1 execute INTEREST_DB --remote --command "PRAGMA foreign_key_check; PRAGMA integrity_check;"Stop unless foreign_key_check returns no rows and integrity_check returns
exactly ok. Run these exact parity queries against the migrated database and
stop unless each count is zero:
SELECT count(*) AS missing_policiesFROM release_accounts aLEFT JOIN release_access_policies p ON p.email = a.emailWHERE p.email IS NULL;
SELECT count(*) AS latest_mismatchesFROM release_accounts aJOIN release_access_policies p ON p.email = a.emailWHERE p.include_latest <> a.include_latest;D1 is authoritative for recipient accounts, password metadata, Latest policy, explicit-version grants, sessions, and release-access events. Recipient identity is unrelated to the local application PostgreSQL database.
3. Create and lock down private R2
Section titled “3. Create and lock down private R2”Create the bucket once:
npx wrangler r2 bucket create backchannel-desktop-releasesIn the R2 dashboard, disable the bucket’s r2.dev development URL and remove
or disable every bucket custom domain. Verify anonymous requests cannot reach
either path. The only delivery path is the authenticated Worker binding.
4. Bind R2 and configure Worker hosts
Section titled “4. Bind R2 and configure Worker hosts”docs-site/wrangler.jsonc must bind bucket backchannel-desktop-releases as
RELEASES, include downloads.backchannel.page as a custom-domain route, and
keep both workers_dev and preview_urls false. The existing public and admin
custom domains remain. Confirm DNS and certificate activation before continuing.
5. Configure recipient abuse controls
Section titled “5. Configure recipient abuse controls”Create a managed Turnstile widget for recipient login with exactly:
- hostname:
downloads.backchannel.page - action:
download_login - pre-clearance: disabled
Put its public site key in site/downloads/index.html and enter the secret
interactively:
npx wrangler secret put TURNSTILE_SECRETCreate a Cloudflare rate-limit rule matching method POST and path
/api/download/login on downloads.backchannel.page. Rate limit before the
Worker executes, use an operator-approved low per-IP threshold, and return a
generic denial. Do not weaken the Worker’s same-origin, body-size, generic
authentication, or exact Turnstile hostname/action checks.
6. Merge the control-plane branch and deploy Worker/assets together
Section titled “6. Merge the control-plane branch and deploy Worker/assets together”Retain the public-interest Turnstile secret separately as
TURNSTILE_SECRET_KEY. Protect all of admin.backchannel.page with the
existing Cloudflare Access self-hosted application and one exact-email Allow
policy. Enter its values interactively:
npx wrangler secret put TURNSTILE_SECRET_KEYnpx wrangler secret put ADMIN_EMAILnpx wrangler secret put ACCESS_TEAM_DOMAINnpx wrangler secret put ACCESS_AUDnpm run deploynpm run deploy synchronizes and builds the site immediately before invoking
Wrangler. Never deploy a preexisting dist-site or invoke Wrangler directly
for production deployment. The Worker and complete admin shell (admin.js,
admin-core.js, early-access.js, users.js, and authorization.js) are one
cutover unit; never deploy either side separately.
Do not add a broad group/domain Include, Bypass, or Service Auth rule. The
Worker must independently validate the Access JWT signature, Cloudflare issuer,
configured audience, and exact case-insensitive ADMIN_EMAIL; missing or
invalid configuration fails closed.
Only after the atomic deployment succeeds may the recorded mutation freeze end. Smoke test Early access approve/reject and the one-time credential, Users password reset/session sign-out/revoke, and Authorization grant replacement with test recipients. Then confirm recipient login, forced password change, release visibility, revocation, session behavior, and downloads.
Signed-out and wrong-identity requests must not reach admin assets or APIs. The
admin surface must remain private and Cache-Control: no-store; never log
credentials, sessions, subscriber data, Access assertions, or R2 keys.
Rollback is unsafe after the first new policy mutation because the unused
legacy release_accounts.include_latest value may be stale. After that point,
recover with a forward fix or an explicit policy-to-legacy synchronization
before restoring the previous Worker. Keep the previous Worker available only
for rollback before the first new policy mutation.
Merge this rollout to master with a merge commit that preserves hold commit
57fc8d991b8101a2db5889df16ce5a26078baff2. Do not squash or rebase this
rollout. Push master, wait for the Site workflow to finish successfully, then
fetch the deployed branch and prove the hold is present:
git fetch origin mastergit merge-base --is-ancestor 57fc8d991b8101a2db5889df16ce5a26078baff2 origin/masterif ($LASTEXITCODE -ne 0) { throw 'The download-link hold is not an ancestor of origin/master.' }Stop unless the Site workflow is green and git merge-base --is-ancestor
exits 0. Do not create or migrate any live R2 catalog object, benchmark PBKDF2,
or approve a recipient before both checks pass.
7. Configure an independent R2 writer
Section titled “7. Configure an independent R2 writer”Create a bucket-scoped Cloudflare R2 API token with Object Read & Write
permission for backchannel-desktop-releases. Cloudflare exposes its
S3-compatible writer credentials as access-key and secret fields. Configure the
GitHub production environment with secrets CLOUDFLARE_ACCOUNT_ID,
R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY, plus variable
R2_RELEASES_BUCKET=backchannel-desktop-releases. These credentials are
separate from the site deployment token; do not expand that token.
The checked-in scripts/r2-object.mjs client calls Cloudflare R2 directly and
is the only release object transport. AWS4-HMAC-SHA256 and x-amz-* are the
protocol field names Cloudflare requires for its S3-compatible API; they are
not AWS credentials or services.
8. Seed and migrate the release catalog
Section titled “8. Seed and migrate the release catalog”Follow Releasing to migrate the historical catalog in version
order. Migrate v0.1.0 once as the seed, verify its immutable manifest and
assets, and create the valid releases/latest.json pointer. Do not migrate the
seed version again. Continue with v0.1.1, v0.2.0, and v0.2.1, verifying
every immutable manifest and asset before advancing Latest. Confirm the
deployed admin GET /api/admin/releases response reports available: true.
9. Benchmark the real password work factor
Section titled “9. Benchmark the real password work factor”On the deployed Worker plan, approve a disposable operator test recipient and perform repeated real login attempts through the hostname-bound Turnstile flow. Confirm Workers observability reports successful 600,000-iteration PBKDF2-HMAC-SHA256 derivations within the plan’s request CPU ceiling, including unknown-account dummy derivation. If the ceiling is insufficient, upgrade the Worker plan before enabling recipient accounts. Never lower the 600,000 iterations.
10. Accept accounts and downloads, then cut over customer links
Section titled “10. Accept accounts and downloads, then cut over customer links”Test approved, expired, revoked, Latest-only, and explicitly granted operator accounts against the completed catalog. Confirm portal downloads match manifest size and SHA-256 without GitHub cookies. After live Task 7 acceptance, make the only link-cutover revision by reverting the exact hold commit:
git revert 57fc8d991b8101a2db5889df16ce5a26078baff2git push origin masterDo not hand-edit customer links or combine unrelated changes with the resulting revert commit. The push automatically builds and deploys the restored portal links and their exact site-test expectations; wait for the Site workflow before declaring cutover complete.
Keep old private GitHub executable files for one full release cycle. Remove those executable files only after R2 and portal acceptance for the following release; keep all GitHub source tags and release notes.
Interest-list operations
Section titled “Interest-list operations”Public POST /api/interest remains Turnstile-protected and stores normalized
consent records in D1. Its widget remains restricted to hostname
backchannel.page, action interest, and no pre-clearance. There is no public
list or mutation route.
List consent records for an approved communication operation:
npx wrangler d1 execute INTEREST_DB --remote --command "SELECT email, status, source, consent_at, invited_at, last_contacted_at FROM interest_subscribers ORDER BY created_at;"Use parameterized statements in the authenticated D1 console to record an invite or unsubscribe:
UPDATE interest_subscribersSET status = 'invited', invited_at = datetime('now'), last_contacted_at = datetime('now')WHERE email = ?;
UPDATE interest_subscribersSET status = 'unsubscribed', last_contacted_at = datetime('now')WHERE email = ?;If a mailing sender is added later, it must provide unsubscribe handling and write that state to D1 before another send. Export only the required consent table directly to an operator-controlled absolute path in approved encrypted storage, then delete it after the record-count check:
$exportPath = $env:BACKCHANNEL_INTEREST_EXPORT_PATHif ([string]::IsNullOrWhiteSpace($exportPath) -or -not [IO.Path]::IsPathRooted($exportPath)) { throw 'BACKCHANNEL_INTEREST_EXPORT_PATH must be an absolute path in approved encrypted storage outside the repository.'}$exportPath = [IO.Path]::GetFullPath($exportPath)$repoRoot = (Resolve-Path ..).Pathif ($exportPath.StartsWith($repoRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'BACKCHANNEL_INTEREST_EXPORT_PATH must be outside the repository.'}npx wrangler d1 export backchannel-interest --remote --table=interest_subscribers --output="$exportPath"Rotate either Turnstile secret by replacing only its encrypted Worker binding and verifying a fresh production request; never print saved secrets into source, logs, screenshots, or issue trackers.