Jitsi — Video Conferencing Infrastructure
Audiences: developer, internal
Self-hosted, JWT-gated Jitsi Meet stack that powers HIPAA-compliant doctor-patient video visits, with server-side recording into MinIO.
Business Purpose
Jitsi is the video pillar of the Dudoxx telemedicine offering. Doctors and patients connect to JWT-scoped meeting rooms keyed to a specific FHIR Appointment / Encounter, so a clinic owns the entire signaling, media, and recording path — no cross-tenant data leaks to a hosted SaaS like Zoom. Recordings stream into the per-tenant MinIO bucket for compliance retention. This single change ("we host the video") is what makes Dudoxx defensible against bundled US/EU GP suites that ship their telemed via a third-party SaaS.
Revenue lever: telemedicine is a per-seat upsell on the core HMS subscription; replacing a Zoom/Teams add-on with an in-product, recorded, FHIR-linked visit removes a per-month per-doctor third-party cost.
Audiences
- Investor: Owning the full stack (signaling, media bridge, recorder, branding) is a moat — every competitor that re-sells Zoom/Doxy.me is locked into their pricing and exposed to data-residency objections.
- Clinical buyer (doctor/nurse/receptionist): "Start visit" inside a calendar appointment opens a Dudoxx-branded room; recording is one click; the file lives alongside the patient chart, not in a separate vendor inbox.
- Developer/partner: 5 containers (web/prosody/jicofo/jvb/jibri) on a private bridge network, JWT minted by NestJS, custom branding overlay. No Jitsi business code is forked — only branding + JWT issuer + room-naming convention.
- Internal (ops/support): Five-container restart contract; the prosody data directory must be owned by uid=100 (
dhcpcd:tsson this host) or auth will silently fail withPermission denied. Runsudo ./fix-permissions.shafter any git pull.
Architecture
Self-hosted Jitsi Meet (stable-10978) is decomposed into five purpose-built containers, all living on a private bridge network ddx-jitsi (or attached to the umbrella stack network in full-HMS mode). Only the web frontend and the JVB media port are exposed to the host; all XMPP signaling and management ports are internal-only.
Browser ──HTTPS──► jitsi-web (Nginx) ──XMPP/HTTP-bind──► jitsi-prosody ◄── JWT validate
│
├── jitsi-jicofo (conf focus)
├── jitsi-jvb (WebRTC bridge, UDP 10000)
└── jitsi-jibri (Chrome-headless recorder)
ddx-api (NestJS) ──mint JWT (HS256)──► Browser ──join with token──► jitsi-web
Container roles, in dependency order:
- jitsi-prosody — XMPP signaling + JWT validation, only host that holds the shared secret.
- jitsi-jicofo — conference lifecycle, allocates a JVB instance per room.
- jitsi-jvb — Selective-Forwarding Unit (SFU), only port exposed externally for media (UDP
JITSI_JVB_PORT, default 15100 in HMS, 10000 upstream). - jitsi-jibri — privileged container running headless Chromium for server-side recording; writes MP4 to
./recordingsthen a webhook calls back into ddx-api. - jitsi-web — Nginx frontend with custom Dudoxx branding (
config/web/css/custom.css,config/web/branding/branding.json).
The video domain in ddx-api is src/voice-video/video/ — it mints JWTs and stores meeting metadata; it does NOT proxy media. Media goes browser → JVB directly (WebRTC).
For full system context see coding_context/ddx-hms-context.md and the umbrella deployment in infra-full-hms-compose.
Tech Stack & Choices
- Jitsi Meet
stable-10978(Apache 2.0) — chosen because it is the only mature self-hostable WebRTC SFU + signaling + recording stack; LiveKit ships an SFU but no recorder + no JWT plug-in story for browser-based video rooms. - Why not LiveKit for video? LiveKit is used for the voice agent (real-time STT + agent turn-taking); a video visit room is a longer-lived multi-party session with recording — see infra-livekit for the boundary. LiveKit = voice FlowAgent + ambient room audio; Jitsi = doctor-patient video rooms (memory:
MEMORY.mdtopicfeedback_voice_state_machine_design). - JWT HS256 — symmetric secret shared between ddx-api (issuer) and prosody (validator). Asymmetric RS256 was rejected: same trust domain, no third-party verifier, HS256 halves CPU on signing path.
- VP9 preferred over VP8 — better quality at telemedicine bitrates (~800 kbps start, adaptive).
- Headless Chrome (Jibri) — Jitsi-stock recorder; alternatives (FFmpeg pipe out of JVB) ruled out because no upstream support and would need bespoke layout rendering.
- Bridge network
ddx-jitsi— isolated; onlyjitsi-webHTTPS port +jitsi-jvbUDP port hit the host.
Data Flow
Meeting creation
- Frontend calls
POST /video/meetingson ddx-api with anappointmentId. VideoServicewrites aVideoMeetingrow, thenJitsiJwtService.generateMeetingJwt(...)mints a short-lived HS256 token with:iss: dudoxx_clinicaud: jitsisub: meet.jitsi(matches prosodyVirtualHost)room: ddx-{clinic-slug}-{appointment-short}-{random}context.user: name, email, FHIR practitioner idcontext.features:{ moderator, recording }
- Browser opens
https://meet.dudoxx.local:15543/<room>?jwt=<token>. jitsi-webBOSH/WebSocket-bridges intojitsi-prosody; prosody validates the JWT against the sharedJWT_APP_SECRET.- On success,
jitsi-jicofoallocates a JVB; the browser starts WebRTC againstjitsi-jvb(UDP 15100 or TCP 15443 fallback).
Recording
- Moderator (JWT claim
moderator: true) hits "Record". - Jicofo allocates a
jitsi-jibriinstance from its brewery MUC. - Jibri launches headless Chrome → joins the room with a
recorder@recorder.meet.jitsiJID. - Stream is captured to
/recordings/<room>-<date>.mp4(volume-mounted to./recordings). - Jibri fires a webhook back to ddx-api (consumed by
voice-video/video/video.controller.ts), which moves the file into the tenant's MinIO{clinic-slug}-recordingsbucket — see infra-minio.
SSE: no Jitsi-specific events are published on the canonical SSE bus today; visit lifecycle events (visit.started, visit.ended) are emitted by the visits domain on SSEChannels.session(id) (wire session:{id}) once the JWT is dispensed.
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-docker-jitsi/docker-compose.yml:12—name: ${COMPOSE_PROJECT_NAME:-ddx-hms-${DUDOXX_HMS_SETUP_ID:-1}}(liveCOMPOSE_PROJECT_NAME=ddx-001→ containersddx-001-jitsi-*via theJITSI_*_CONTAINERenv vars); full 5-container stack on theddx-jitsibridge network.ddx-docker-jitsi/docker-compose.yml:17—image: jitsi/web:stable-10978(all five containers pinned tostable-10978).ddx-docker-jitsi/docker-compose.yml:21—${JITSI_HTTP_PORT:-15180}:80and${JITSI_HTTPS_PORT:-15543}:443host port mapping (Jitsi keeps the 15xxx telemed band — the documented exception to the 6xxx dev band, percontext/ENVIRONMENT.md).ddx-docker-jitsi/docker-compose.yml:218— JVB media port${JITSI_JVB_PORT:-15100}:${JITSI_JVB_PORT:-15100}/udpand TCP fallback15443.ddx-docker-jitsi/docker-compose.yml:176—jitsi-jibriprivileged container withSYS_ADMINcap for Chrome.ddx-docker-jitsi/docker-compose.yml:214—ddx-jitsibridge network definition.ddx-docker-jitsi/ARCHITECTURE.md:71— Jicofo conference focus responsibilities + bridge allocation policy.ddx-docker-jitsi/ARCHITECTURE.md:147— JWT token flow sequence diagram (Client → NestJS → Jitsi → Prosody).ddx-api/src/voice-video/video/jitsi-jwt.service.ts:13—export class JitsiJwtService— mints + verifies meeting tokens.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:22—this.appSecret = this.configService.get<string>('JITSI_JWT_SECRET') || ''— shared secret read from env.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:60— JWT payload assembly (iss,aud,sub,room,context.user,context.features).ddx-api/src/voice-video/video/jitsi-jwt.service.ts:75—jwt.sign(payload, this.appSecret, ...)HS256 signing.ddx-api/src/voice-video/video/video.module.ts:11—VideoModulewiresVideoController,VideoService,JitsiJwtService,VideoEventsPublisher.ddx-docker-full-hms/docker-compose.yml:831—jitsi-webservice registration inside the umbrella stack (profile:jitsi).
Operational Notes
- Prosody permission pitfall (high-frequency support hit):
jitsi-config-1/prosody/config/data/MUST be owned bydhcpcd:tss(uid=100:gid=102) because the prosody process drops privileges to uid=100. Symptom:connection.passwordRequiredin the browser; prosody log showsFailed to load roster storage ... Permission denied. Fix:sudo ./fix-permissions.shfromddx-docker-jitsi/. - JWT domain mismatch is the #2 support hit: prosody
VirtualHost "meet.jitsi"MUST match the JWTsubclaim.JITSI_DOMAIN=meet.jitsi(internal XMPP domain) — NOT the public URL.JITSI_PUBLIC_URL=https://meet-tucan.dudoxx.comis browser-facing only. - Apache vhost rule (production): do NOT add
ProxyPass /css/ !orProxyPass /images/ !in the public vhost. Jitsi serves its own static assets — Apache must proxy everything. - LiveKit vs Jitsi confusion (REMINDERS pitfall): LiveKit (
localhost:36880, voice agent RTC) and Jitsi (localhost:15180/15543, video rooms) serve different use cases. Do NOT route video visits through LiveKit, and do NOT route VoiceAgent through Jitsi. - Recording storage: Jibri writes to a local volume first; the post-recording webhook moves into per-tenant MinIO bucket
{clinic-slug}-recordings— failed webhooks leave orphan MP4s under./recordings/. Cleanup script needed in ops. - Firewall: only
${JITSI_JVB_PORT:-15100}/udpmust be open for media. TCP fallback15443is for restrictive client networks. - Health validation:
bash
curl -k https://meet.dudoxx.local:15543/about/ # web reachable docker logs ddx-001-jitsi-prosody-1 --tail 30 | grep "Authenticated as" # JWT OK
Related Topics
- infra-livekit — voice/agent RTC; complementary, NOT a substitute
- infra-traefik — TLS termination + routing for
meet-*.dudoxx.com - infra-full-hms-compose — umbrella stack that activates Jitsi via
--profile jitsi - infra-minio — recording archive bucket convention
- CLAUDE_DEPLOYMENT_STT_ENGINE — companion deployment guide