15 KiB
AGENTS.md
Guidance for AI coding agents (and humans) working in the captions-deploy repository. Read this before changing anything.
What this repository is
This is the deployment orchestration repository for a self-hosted live captioning stack. It contains Docker Compose files, a Traefik edge-router config, Nginx config, deployment docs and secret templates. It is not the application source.
The application code lives in three Git submodules. These are patched forks
maintained under captions.coop, not read-only mirrors — they can and should be
edited when a deployment change needs application code:
| Path | Fork remote | Basis | Role |
|---|---|---|---|
caption-local/ |
git.coopcloud.tech/captions.coop/caption-local.git |
steveseguin/caption-local |
Whisper speech-to-text service (Python/FastAPI) |
captionninja/ |
git.coopcloud.tech/captions.coop/captionninja.git |
steveseguin/captionninja |
Static web UI, room/editor/audience pages + private WebSocket relay (Node) |
captions-coop-wiki/ |
git.coopcloud.tech/captions.coop/captions-coop-wiki.git |
new (captions.coop) |
Audience documentation wiki (MkDocs Material) |
Submodules are not initialised in a plain checkout. Run
git submodule update --init --recursive before building.
Application logic belongs in the submodules, not duplicated here; this repo owns Compose files, Traefik/Nginx config, docs and scripts. When a change needs app code, patch the submodule fork — do not reimplement it in the deploy repo.
Fork workflow
- Edit and test inside the submodule directory, following that project's docs and test commands.
- Commit in the submodule repo (separate history;
caption-localand the relay/browser helper are MPL-2.0). Push to thecaptions.coopfork. - Bump the pointer here as part of the same change:
git add caption-local(orcaptionninja) so the parent records the new commit. - Keep deploy docs in sync, and call out the submodule bump in the commit message so the app change is not hidden inside a pointer update.
The captions-coop-wiki submodule follows the same commit-and-push model. For now
there is no pull request gate on it; its pointer bump is added here as usual.
Repository layout
.
├── docker-compose.yml # base stack: 4 services, loopback ports
├── docker-compose.prod.yml # production override: Traefik, TLS, UI/wiki hostnames
├── docker-compose.auth.yml # opt-in HTTP Basic Auth perimeter
├── traefik.yml # static Traefik config (entrypoints, ACME, docker provider)
├── web/ # UI image: nginx template + same-origin API/WS proxy
├── wiki/ # docs image: builds the wiki submodule, serves the static site
├── .env.production # Compose env file (ACME email + UI/wiki hostnames) — TRACKED, non-secret
├── docs/CONFIGURATION.md # detailed config reference
├── secrets/ # git-ignored; relay.json + caption-local.env + htpasswd
├── caption-local/ # submodule (Whisper STT service, room registry, capture page)
├── captionninja/ # submodule (UI, room/watch/editor pages, relay)
└── captions-coop-wiki/ # submodule (MkDocs docs wiki)
Services
| Service | Build context / image | Internal port | Purpose |
|---|---|---|---|
caption-local |
./caption-local |
8765 |
Whisper STT API, room registry, capture page |
captionninja-relay |
./captionninja/relay |
8787 |
WebSocket caption relay; derives rooms/tokens from the shared master |
captionninja-web |
web/Dockerfile (nginx) |
80 |
Public UI and same-origin proxy for API, capture page and /ws |
wiki |
wiki/Dockerfile (nginx) |
80 |
MkDocs documentation site |
traefik |
traefik:v3.7.14 |
80/443 |
Edge router, TLS termination (prod only) |
In the base file all four app ports bind to loopback only
(127.0.0.1:*). In production Traefik publishes only UI_HOST and WIKI_HOST.
Public traffic reaches the capture API and relay exclusively through the nginx
proxy. Never add a public Traefik router for caption-local or the relay.
Access model (do not regress)
- Self-service rooms:
POST /api/rooms. Each room returns a private admin link (/room/<id>#key=<secret>) and a public audience link (/watch/<id>). - The admin secret is stored as a SHA-256 digest only. Sessions are signed
HttpOnly,SameSite=Laxcookies on the UI origin (caption_room). - Relay source/output rooms and read/write tokens are HMAC derivations of the
shared
master; do not add per-room relay configuration. - Admin links persist until rotated or the room is closed. There is no transcript history.
CAPTION_ADMIN_KEYis only the operator console key for/admin.htmland/api/admin/rooms; it is not the room auth mechanism.docker-compose.auth.ymlis an optional whole-site perimeter lock, not the room model.- Keep the single-origin proxy: same-origin cookies depend on it. Do not send relay tokens or room secrets to third-party hosts.
Prerequisites
git≥ 2.30- Docker Engine +
docker composev2.x htpasswd(fromapache2-utils) only if enabling the Basic Auth perimeter- Internet access on first run (submodules, images, Whisper model, translation models)
First-time setup
git clone --recurse-submodules <repo-url>
cd captions-deploy
# or, if already cloned:
git submodule update --init --recursive
1. Relay private config
Generate secrets/relay.json and give it to uid/gid 1000:
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
docker run --rm -v "$PWD/secrets/relay.json":/c alpine \
sh -c 'chown 1000:1000 /c && chmod 600 /c'
Edit origins to the exact UI origin(s) — scheme included, no trailing slash.
With the single-origin model this is normally just the UI, plus localhost for
local testing. Recreate the relay after editing.
2. Caption service env
Create secrets/caption-local.env (git-ignored). CAPTION_RELAY_MASTER must be
the same value as secrets/relay.json master:
CAPTION_RELAY_MASTER=<base64url master>
CAPTION_ADMIN_KEY=<at least 24 ASCII chars, optional>
RELAY_ADDRESS=wss://ui.example.org/ws
RELAY_SITE=https://ui.example.org/
CAPTION_ROOM_CREATION=open
CAPTION_COOKIE_SECURE=1
For local HTTP use ws://localhost:8080/ws,
RELAY_SITE=http://localhost:8080/ and CAPTION_COOKIE_SECURE=0.
Room state is stored in the caption-local-keys volume at /data/rooms.db
(Compose sets CAPTION_ROOMS_DB=/data/rooms.db). The legacy CAPTION_KEYS_DB
name is only a fallback; do not use it in new docs.
CAPTION_API_KEY, invite keys and per-user key files are removed. Do not
reintroduce them.
3. Basic Auth (optional)
mkdir -p secrets
htpasswd -nbB admin 'mySecretPassword' > secrets/traefik.htpasswd
Consumed only by docker-compose.auth.yml.
Common commands
Local development (loopback only, no TLS)
docker compose up --build -d
docker compose logs -f
# UI / room links: http://localhost:8080
# Whisper (direct): http://localhost:8765
docker compose down # keep downloaded models
Production (Traefik + Let's Encrypt TLS)
--env-file .env.production is required so ${LETSENCRYPT_EMAIL} reaches
Traefik's ACME resolver.
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml up -d
With the Basic Auth perimeter:
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.auth.yml up -d
Operations
# Health of every service (expect `healthy` for all)
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml ps
# Tail logs
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml logs -f
# Validate Compose and nginx config
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml config -q
# Force TLS cert renewal after DNS changes
docker compose -f docker-compose.yml -f docker-compose.prod.yml down traefik
docker volume rm $(docker volume ls -q | grep letsencrypt)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d traefik
# Rebuild one service after a label/nginx/config change
docker compose --env-file .env.production \
-f docker-compose.yml -f docker-compose.prod.yml up -d --build --force-recreate captionninja-web
Testing & verification
There is no root-level lint/test tooling. Verify deploy changes by bringing
up the stack and checking docker compose ps shows healthy, then exercise a
room: create it, open its admin link, fetch /api/rooms/<id>/config, connect a
WebSocket through /ws, rotate the admin link, and close the room.
Component-level commands (run inside the submodule and only if the change touches submodule code):
caption-local (Python 3.12)
cd caption-local
python -m pip install -r requirements-dev.txt # pytest, playwright, httpx, ...
pytest # includes room and portal tests
./start.sh # bootstrap + run (CPU)
python deploy.py setup|run|download|doctor # portable launcher
Node helper tests also live there:
node --test tests/*.cjs
deploy.py run flags: --model (default small), --device cpu|cuda|auto,
--compute-type, --workers (1–8), --threads, --max-streams (1–64),
--beam-size (1–5), --port (default 8765), --offline, --log-requests.
GPU builds use Dockerfile.gpu + compose.gpu.yaml.
captionninja/relay (Node ≥ 24)
cd captionninja/relay
npm ci --ignore-scripts
npm test # node --test test/*.test.cjs
node server.cjs # reads $CAPTION_RELAY_CONFIG
captionninja (static site)
No build, test or lint step. It is plain static HTML/CSS/JS served by Nginx.
Syntax-check edited scripts with node --check.
Configuration reference
- Hostnames:
.env.productiondefinesUI_HOSTandWIKI_HOSTonly;docker-compose.prod.ymlroutes those to Traefik.CAPTURE_HOSTandRELAY_HOSTare gone. - Same-origin proxy:
web/default.conf.templatemust keep forwarding/api/*,/health,/relay-config,/transcribe,/streams/*,/v1/*,/static/*,/capture-local.htmland/admin.htmltocaption-local:8765withHost: 127.0.0.1:8765, and/wsto the relay./room/<id>,/room/<id>/editand/watch/<id>serve the static pages. - TrustedHost:
caption-localonly acceptslocalhost/127.0.0.1/[::1]. The nginx proxy sets the backendHostheader; do not remove it. - Relay
origins: must contain the exact UI page origin. A missing/mismatched origin gives the publisher anHTTP 403/ WebSocket denial. - Master secret: must be byte-identical in
secrets/relay.jsonandsecrets/caption-local.env. A mismatch yields “Join denied”. - Room DB: persisted in the
caption-local-keysvolume at/data/rooms.db; deleting it invalidates all admin links and sessions. - Cookies:
caption_room,HttpOnly,SameSite=Lax,Path=/,Secureby default on HTTPS.CAPTION_COOKIE_SECUREmay force1or0;autois the default. Do not move room sessions into localStorage. - Offline LAN:
.env.lan.examplebinds the web UI to a LAN address. Plainws:///http://relay URLs are allowed only withCAPTION_ALLOW_INSECURE_LAN=1, and only for private LAN hosts. Keep Whisper and relay ports loopback-only. - Full details:
docs/CONFIGURATION.md.
Conventions
- Do not add comments to YAML/config/scripts unless asked. Keep config files minimal and match surrounding style.
- Prefer editing existing files; do not create new docs unless requested.
- No AI co-author trailers. AI help is credited in
README.mdunder Acknowledgements. - Keep
CHANGELOG.mdupdated in the same change. Add entries under the current date (## YYYY-MM-DD) grouped byAdded/Changed/Removed/Fixed/Security/Docs. Link each commit reference to its repo with the short SHA. Fold submodule changes into the parent entry and call out the submodule SHA inline. - Use long-form Compose flags in docs (
--env-file,-f) so production and dev commands stay copy-pasteable. - Keep the loopback-only port model; do not widen exposure silently.
- Changes to hostnames, ports, middleware or nginx proxy routes affect room cookies and WebSockets; verify with a real browser request after reloading.
Documentation changes
Audience-facing docs live in the captions-coop-wiki/ submodule. Agents may
commit and push directly to its main; no pull request is required, and a human
reviews afterwards. The deploy repo's own docs (README.md,
docs/CONFIGURATION.md, this file) are edited directly.
- Work in the submodule, then bump the pointer here (
git add captions-coop-wiki) as part of the same parent change. mkdocs build --strictmust pass before pushing.- Keep
_sources.mdin the wiki in sync. - The wiki documents the self-service room-link model on
main, not the removed invite/per-user-key work.
Security rules
- Never commit secrets.
secrets/,*.private.jsonand.env.productionare git-ignored — but.env.productionis already tracked, so treat it as non-secret and keep credentials out of it. - Never log or echo
CAPTION_RELAY_MASTER,CAPTION_ADMIN_KEY, room admin secrets, relay tokens or htpasswd contents. - Do not expose
caption-localor the relay directly to the internet. - Do not place relay tokens or admin secrets in query strings, logs, analytics or static pages. The admin secret belongs in the URL fragment and is exchanged for a cookie.
- Do not enable Basic Auth by editing
docker-compose.prod.yml; use thedocker-compose.auth.ymloverlay.
Troubleshooting quick reference
| Symptom | Cause / fix |
|---|---|
404 from POST /api/rooms |
Missing CAPTION_RELAY_MASTER / RELAY_ADDRESS, or creation disabled |
400 Bad Request from Whisper |
Nginx proxy is not setting Host: 127.0.0.1:8765 |
401 on /api/me, /transcribe or /v1/* |
Missing/expired room cookie; open the admin link again |
| Relay “Join denied” | origins mismatch or relay/caption-local master mismatch |
| Relay “Relay startup failed” | secrets/relay.json missing or not readable by uid/gid 1000 |
| “Invalid or expired room link” | Admin link was rotated/closed, or the fragment was lost; use the latest link |
| Audience overlay shows “room has ended” | Room closed or wrong public ID |
| ACME rate-limited | Delete the letsencrypt volume, wait, retry |
| Build fails: no submodule content | git submodule update --init --recursive |
| Port 80/443 in use | Free the ports or change prod mappings |
| Basic Auth prompt never appears | Overlay not applied, or secrets/traefik.htpasswd missing |
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).