Files

15 KiB
Raw Permalink Blame History

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

  1. Edit and test inside the submodule directory, following that project's docs and test commands.
  2. Commit in the submodule repo (separate history; caption-local and the relay/browser helper are MPL-2.0). Push to the captions.coop fork.
  3. Bump the pointer here as part of the same change: git add caption-local (or captionninja) so the parent records the new commit.
  4. 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=Lax cookies 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_KEY is only the operator console key for /admin.html and /api/admin/rooms; it is not the room auth mechanism.
  • docker-compose.auth.yml is 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 compose v2.x
  • htpasswd (from apache2-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.production defines UI_HOST and WIKI_HOST only; docker-compose.prod.yml routes those to Traefik. CAPTURE_HOST and RELAY_HOST are gone.
  • Same-origin proxy: web/default.conf.template must keep forwarding /api/*, /health, /relay-config, /transcribe, /streams/*, /v1/*, /static/*, /capture-local.html and /admin.html to caption-local:8765 with Host: 127.0.0.1:8765, and /ws to the relay. /room/<id>, /room/<id>/edit and /watch/<id> serve the static pages.
  • TrustedHost: caption-local only accepts localhost/127.0.0.1/[::1]. The nginx proxy sets the backend Host header; do not remove it.
  • Relay origins: must contain the exact UI page origin. A missing/mismatched origin gives the publisher an HTTP 403 / WebSocket denial.
  • Master secret: must be byte-identical in secrets/relay.json and secrets/caption-local.env. A mismatch yields “Join denied”.
  • Room DB: persisted in the caption-local-keys volume at /data/rooms.db; deleting it invalidates all admin links and sessions.
  • Cookies: caption_room, HttpOnly, SameSite=Lax, Path=/, Secure by default on HTTPS. CAPTION_COOKIE_SECURE may force 1 or 0; auto is the default. Do not move room sessions into localStorage.
  • Offline LAN: .env.lan.example binds the web UI to a LAN address. Plain ws:///http:// relay URLs are allowed only with CAPTION_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.md under Acknowledgements.
  • Keep CHANGELOG.md updated in the same change. Add entries under the current date (## YYYY-MM-DD) grouped by Added/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 --strict must pass before pushing.
  • Keep _sources.md in 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.json and .env.production are git-ignored — but .env.production is 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-local or 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 the docker-compose.auth.yml overlay.

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).