2026-10-16 15:05:00 +00:00
2026-10-08 17:32:45 +00:00

Caption Stack

Self-hosted live captioning with a local Whisper speech-to-text service, a private WebSocket relay, a caption editor and a public audience overlay.

repo

This repository is the deployment orchestration layer. The application code lives in three Git submodules:

Path Role
caption-local/ Whisper speech-to-text service (Python/FastAPI) and capture page
captionninja/ Static web UI, caption editor/overlays and the private relay (Node)
captions-coop-wiki/ Audience documentation wiki (MkDocs Material)

How access works

Rooms are self-service. Anyone who can reach the UI can create a room and receives two links:

  • Private admin link — https://ui.example.org/room/<room>#key=<secret>. Keep it private. It can caption, edit, replace the link and close the room.
  • Public audience link — https://ui.example.org/watch/<room>. Share it with viewers, OBS or venue projection. It only reads reviewed captions.

The admin secret is stored only as a hash. Room sessions use an HttpOnly cookie on the UI origin. Relay room names and tokens are derived from one shared master secret, so the relay needs no per-room configuration. Links stay valid until you replace the admin link or close the room; there is no transcript history.

All public traffic goes through one origin (UI_HOST). Nginx proxies the hidden caption-local and relay services internally.

Prerequisites

  • git ≥ 2.30
  • Docker Engine with the Compose v2 plugin (docker compose)
  • Internet access on first run (submodules, images and the Whisper model)
  • Ports 80 and 443 free (production only)
  • htpasswd from apache2-utils only if you enable the optional Basic Auth perimeter

Getting started

1. Clone with submodules

git clone --recurse-submodules https://git.coopcloud.tech/captions.coop/captions-deploy.git
cd captions-deploy

If the submodules were not initialised:

git submodule update --init --recursive

2. Create the relay configuration

The relay reads a private JSON file containing the shared master secret and the exact browser origins allowed to connect. It is git-ignored.

mkdir -p secrets
docker run --rm -v "$PWD/captionninja/relay":/w -v "$PWD/secrets":/s -w /w node:24-alpine \
  node init-config.cjs /s/relay.json

The relay container runs as uid/gid 1000:

docker run --rm -v "$PWD/secrets/relay.json":/c alpine \
  sh -c 'chown 1000:1000 /c && chmod 600 /c'

Set origins to the exact UI origin, for example https://ui.example.org (scheme included, no trailing slash). The local stack defaults already contain http://localhost:8080 and http://127.0.0.1:8080.

To read the generated master for the next step:

docker run --rm -v "$PWD/secrets/relay.json":/c alpine cat /c

3. Configure the caption service

Create secrets/caption-local.env (git-ignored). The master must match the relay file; otherwise the relay rejects every room:

CAPTION_RELAY_MASTER=<base64url master from secrets/relay.json>
RELAY_ADDRESS=wss://ui.example.org/ws
RELAY_SITE=https://ui.example.org/
CAPTION_ADMIN_KEY=<at least 24 ASCII characters, no whitespace>
CAPTION_ROOM_CREATION=open

For the local stack use:

CAPTION_RELAY_MASTER=<same master>
RELAY_ADDRESS=ws://localhost:8080/ws
RELAY_SITE=http://localhost:8080/
CAPTION_ADMIN_KEY=<at least 24 ASCII characters>

CAPTION_ADMIN_KEY is optional. It protects the operator room console at /admin.html and the /api/admin/rooms API. It is not needed to create, caption or close a room through its admin link.

CAPTION_ROOM_CREATION=off disables self-service creation. Rate limits default to five new rooms per IP per hour and 400 active rooms.

4. Start the stack

Local (loopback ports, no TLS):

docker compose up --build -d
docker compose logs -f

Production (Traefik + Let's Encrypt TLS):

docker compose --env-file .env.production \
  -f docker-compose.yml -f docker-compose.prod.yml up -d

--env-file .env.production is required so ${LETSENCRYPT_EMAIL} reaches Traefik.

Using the stack

  1. Open the UI (http://localhost:8080 locally, or https://ui.example.org).
  2. Create a room and copy the private admin link.
  3. Open the admin link. From the room page, start captioning, open the editor, or copy the public audience link.
  4. In the capture page choose a microphone and press Start captions.
  5. Open the caption editor to review text before it reaches the audience overlay. Use its Unreviewed publishing mode to send new captions straight to the audience without review.
  6. Add the public audience link to OBS as a Browser Source, or share it with viewers.

The capture page and editor share the room cookie. Do not share the admin link with viewers; it grants full control of the room.

Docker Compose overview

docker-compose.yml defines four application services. The production override adds Traefik and hostnames.

Service Build context / image Port(s) Purpose
caption-local ./caption-local 127.0.0.1:8765:8765 Whisper STT API and capture page
captionninja-relay ./captionninja/relay 127.0.0.1:8787:8787 Private WebSocket relay
captionninja-web web/Dockerfile (nginx) 127.0.0.1:8080:80 Public UI, same-origin proxy for rooms, API and /ws
wiki wiki/Dockerfile (nginx) 127.0.0.1:8081:80 MkDocs documentation wiki
traefik traefik:v3.7.14 80:80, 443:443 Edge router and TLS (production only)

All application ports bind to loopback. In production Traefik publishes only UI_HOST and WIKI_HOST; the raw Whisper and relay services are not reachable publicly.

Production deployment

Edit the tracked, non-secret .env.production:

LETSENCRYPT_EMAIL=you@example.com
UI_HOST=ui.example.org
WIKI_HOST=wiki.example.org

Both hostnames need DNS records pointing at the host. Traefik obtains TLS certificates for them automatically.

Secrets live only in secrets/:

  • relay.json from step 2, with your production UI origin.
  • caption-local.env from step 3, with wss://${UI_HOST}/ws, https://${UI_HOST}/ and the same master.

Then start the stack:

docker compose --env-file .env.production \
  -f docker-compose.yml -f docker-compose.prod.yml up -d

Access URLs

  • UI and room links: https://ui.example.org
  • Documentation wiki: https://wiki.example.org
  • Relay WebSocket (internal, same origin): wss://ui.example.org/ws

Offline LAN deployment

For an isolated LAN event, run the base stack without Traefik and expose only the nginx UI on the LAN address:

cp .env.lan.example .env.lan
# Replace every REPLACE_WITH_LAN_IP and set CAPTION_WEB_BIND to the host LAN IP.
docker compose --env-file .env.lan -f docker-compose.yml up -d --build

For the first online run set CAPTION_OFFLINE=0 so the Whisper model can download. After that, set CAPTION_OFFLINE=1 for the offline event. The audience URL is http://<lan-ip>:8080/watch/<id>. Relay origins must include the exact LAN origin, and usually http://localhost:8080 as well.

Capture on the inference host through http://localhost:8080; browsers generally do not permit microphone access from a plain HTTP LAN IP. Audience and editor pages do not need microphone access and can be opened from other LAN devices.

Optional HTTP Basic Auth

The default stack is open to anyone who can reach the UI. To add a perimeter password (for example on a private staging deployment), create an htpasswd file and add the overlay:

mkdir -p secrets
htpasswd -nbB admin mySecretPassword > secrets/traefik.htpasswd

docker compose --env-file .env.production \
  -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.auth.yml up -d

The overlay attaches Basic Auth to the UI and wiki routers only. Dropping the overlay and redeploying removes the prompt. Room admin links still provide the per-room access model behind the perimeter.

Health-check verification

docker compose --env-file .env.production \
  -f docker-compose.yml -f docker-compose.prod.yml ps

Every service should show healthy. The base file carries the same health checks, so docker compose ps works locally too.

Troubleshooting

  • Build fails: no submodule content — run git submodule update --init --recursive.
  • Relay "Relay startup failed" — secrets/relay.json is missing or not readable by uid/gid 1000; apply chown 1000:1000 and chmod 600.
  • Room creation returns 404 — CAPTION_RELAY_MASTER and RELAY_ADDRESS are not configured in secrets/caption-local.env.
  • "Invalid or expired room link" — the admin link was rotated, the room was closed, or the URL fragment was lost. Use the latest admin link; a room page opened with a valid session can create a fresh one.
  • Overlay shows nothing / relay rejects the browser — the UI origin is missing from the relay origins array, or the master differs between the relay and caption-local. Recreate the relay after editing.
  • Whisper returns 401 — open the page from its room link; the room session cookie is missing or expired.
  • Port conflict — free ports 80/443 or edit the loopback mappings in docker-compose.yml.
  • Let's Encrypt rate limit — delete the letsencrypt volume and retry after an hour.

Further detail: docs/CONFIGURATION.md and AGENTS.md.

Contributing

  1. Fork the repository.
  2. Create a feature branch.
  3. Patch application code in the relevant submodule and bump its pointer here.
  4. Verify with docker compose ... up -d and docker compose ps.
  5. Open a pull request.

Acknowledgements

This stack was developed with help from generative AI using a variety of models.

License

GNU Affero General Public License v3.0 — see LICENSE. The submodules have their own licenses (the relay, browser helper and caption-local are MPL-2.0).

S
Description
Docker Compose deploy for captions.coop
Readme AGPL-3.0
610 KiB
Languages
Dockerfile 81.5%
Shell 18.5%