Bumps caption-local to d29fca6 (option ticked by default, clearer empty panel hint, cast list survives polling), captionninja to 529779a (synced capture bundle) and captions-coop-wiki to 44bb25e (guide update).
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.
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
80and443free (production only) htpasswdfromapache2-utilsonly 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
- Open the UI (
http://localhost:8080locally, orhttps://ui.example.org). - Create a room and copy the private admin link.
- Open the admin link. From the room page, start captioning, open the editor, or copy the public audience link.
- In the capture page choose a microphone and press Start captions.
- 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.
- 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.jsonfrom step 2, with your production UI origin.caption-local.envfrom step 3, withwss://${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.jsonis missing or not readable by uid/gid1000; applychown 1000:1000andchmod 600. - Room creation returns 404 —
CAPTION_RELAY_MASTERandRELAY_ADDRESSare not configured insecrets/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
originsarray, or the master differs between the relay andcaption-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/443or edit the loopback mappings indocker-compose.yml. - Let's Encrypt rate limit — delete the
letsencryptvolume and retry after an hour.
Further detail: docs/CONFIGURATION.md and
AGENTS.md.
Contributing
- Fork the repository.
- Create a feature branch.
- Patch application code in the relevant submodule and bump its pointer here.
- Verify with
docker compose ... up -danddocker compose ps. - 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).