26-Aug-2026 — Video Meetings: One Jitsi Server for the Whole Estate


Background — why build video meetings into a walking group's website?

Committee meetings are where a walking group actually runs itself: the walks programme gets agreed, the accounts get reviewed, the AGM gets planned. Until now, holding one online meant somebody's personal Zoom or Teams account, a join link pasted into an email chain, and minutes typed up afterwards from memory. Issue #357 set out to fold the whole lifecycle into NGX-Ramblers itself: plan the meeting on the site's calendar, send a proper calendar invite, join in the browser with no account, and come away with notes.

The design goals were firm from the start:

Jitsi Meet — open source, browser-native WebRTC, embeddable via its iframe External API — fits all four. The interesting work was everything around it: where calls run, who gets in, how invites travel, and how one container serves every site.

Where a call runs — one toggle, three modes

NGX resolves the Jitsi host through a single configuration value, the same pattern the integration worker uses for its own URL. The resolution order is environment variable, then the global Video Meetings config, then a public default (video-meetings-config.ts):

Mode What is set Where the call runs
Default nothing Jitsi's public meet.jit.si page
Local development JITSI_HOST_URL=https://localhost:8443 a Docker stack on the developer's machine
Self-hosted host URL in Global Settings → Video Meetings the estate's own ngx-ramblers-jitsi app on Fly

The public default exists so the feature works with zero setup, but it comes with a catch: meet.jit.si no longer allows production embedding — an iframe call is treated as a demo and ended after five minutes. So when the resolved host is a public Jitsi domain, NGX opens the room on Jitsi's own page instead (unlimited, but their site and their Google/GitHub sign-in to start a room, and the UI warns about exactly that). When the host is ours, NGX embeds the call in the site via the iframe External API and the experience becomes seamless: member identity, no third-party page, no time limit.

Two derived flags drive everything downstream: publicHost (is the resolved host a public Jitsi domain?) and jwtRequired (JWT credentials are configured and the host is ours). publicHost:false, jwtRequired:true is the self-hosted sweet spot — every request to join a room must carry a token the site issued.

One server, every site

Every hosted group site in the estate is its own Fly app with its own database. Video meetings deliberately break that one-per-group pattern: there is a single Jitsi app for the whole estate, and every site points at it. Meetings are occasional and short — one committee call per group per month or two — so one modest machine covers everyone, and there is one thing to deploy and operate rather than one per group.

The split between what is global and what is per-site is strict. The host URL, JWT app id, room prefix and behaviour toggles live once in the global Video Meetings config, read by every site. The only per-site values are cosmetic: the brand name that appears on invites and the guest instructions text.

flowchart TB
    subgraph estate["The NGX-Ramblers estate — one Fly app per group site"]
        S1@{ icon: "ngx:ramblers", label: "Group site", pos: "b", h: 48 }
        S2@{ icon: "ngx:ramblers", label: "Group site", pos: "b", h: 48 }
        S3@{ icon: "ngx:ramblers", label: "…every other site", pos: "b", h: 48 }
    end

    CONFIG@{ icon: "ngx:mongodb", label: "Global Video Meetings config<br/>host · JWT app id · toggles", pos: "b", h: 48 }

    subgraph jitsi["ngx-ramblers-jitsi — one shared Fly app"]
        JIT@{ icon: "logos:docker-icon", label: "Combined Jitsi container", pos: "b", h: 48 }
    end

    MEMBER@{ icon: "ngx:user", label: "Member<br/>site login → JWT", pos: "b", h: 48 }
    GUEST@{ icon: "ngx:user", label: "Guest<br/>emailed link → JWT", pos: "b", h: 48 }

    CONFIG --> S1
    CONFIG --> S2
    CONFIG --> S3
    S1 -->|"embeds call, issues tokens"| jitsi
    S2 --> jitsi
    S3 --> jitsi
    MEMBER --> S1
    GUEST --> S1

    style estate fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style jitsi fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:12,ry:12,color:#404143

Identity and moderation — the site issues every ticket

The self-hosted server runs JWT-only (AUTH_TYPE=jwt, guests off at the server level). Jitsi itself has no user database and no sign-in page; the only way into a room is a token signed with the shared secret, and the only issuer of those tokens is the NGX server. That makes the site's existing membership system the identity provider for the video server, with no directory to sync.

Two token flavours come out of video-meetings-controllers.ts:

The guest join link that goes out by email is simply /video-meetings/guest/<room> on the group's own site: the site resolves the room, fetches a guest token behind the scenes, and drops the visitor straight into the call. No app to install, no account, allow the camera and microphone and you are in.

A meeting is one document

There is no meetings collection. A planned meeting is a committee file — the same document type that already holds a group's agendas and minutes — with the meeting's details on it: event date, title, agenda text, room name, format (in person, online or hybrid), venue and invitees. One document, so there is nothing to keep in sync.

Everything else derives from that document:

File types carry their own meeting role (agenda or minutes), so meeting types are derived from the file types a group has already configured rather than a parallel taxonomy.

The invite — a real calendar invite, not a link in a paragraph

Planning a meeting composes an email to the chosen invitees — individual guests and any of the group's mailing lists — through the same Brevo transactional pipeline as every other site email. What makes it a first-class invite is the attachment: the server generates an iCalendar document at /api/calendar/meeting/<id>.ics with METHOD:REQUEST, the meeting's date and duration, the join link or venue in the description, and the committee secretary as organiser. Mail clients recognise that shape and render the real thing — Accept / Tentative / Decline buttons, a calendar preview, and one-click add-to-calendar (calendar-controllers.ts).

One wrinkle worth recording: Brevo fetches attachments by URL, which fails for a developer's localhost and for anything it cannot reach. The attachment layer detects that case, generates the .ics server-side, stores it in the site's S3 email-attachments folder and hands Brevo the public URL instead — so the same plan-and-invite flow works identically in development and production.

flowchart LR
    PLANNER@{ icon: "ngx:user", label: "Committee member<br/>plans on calendar", pos: "b", h: 48 }
    NGX@{ icon: "ngx:ramblers", label: "NGX site", pos: "b", h: 48 }
    DB@{ icon: "ngx:mongodb", label: "Committee file<br/>date · agenda · room · format", pos: "b", h: 48 }
    ICS@{ icon: "ngx:file", label: ".ics invite<br/>METHOD:REQUEST", pos: "b", h: 48 }
    BREVO@{ icon: "ngx:brevo", label: "Brevo<br/>transactional email", pos: "b", h: 48 }
    INVITEE@{ icon: "ngx:user", label: "Invitee<br/>RSVP + add to calendar", pos: "b", h: 48 }
    JITSI@{ icon: "logos:docker-icon", label: "Self-hosted Jitsi", pos: "b", h: 48 }

    PLANNER --> NGX
    NGX --> DB
    DB --> ICS
    ICS --> BREVO
    BREVO --> INVITEE
    INVITEE -->|"guest link + JWT"| JITSI

Self-hosting — one container that Fly will actually run

The standard Jitsi deployment is four cooperating containers (prosody for XMPP signalling, jicofo for conference focus, jvb for the video bridge, and the web front end) orchestrated by Docker Compose. Fly's model is one container per machine, and running four machines for a service that is idle most of the month would be wasteful anyway. So the estate image is a combined container: it bases on the official jitsi/prosody image and grafts the web (nginx), jicofo and jvb services onto it, letting the standard s6 /init supervise all four processes in one machine, driven entirely by environment variables (jitsi/Dockerfile).

Networking is the part Jitsi is famous for getting wrong on new infrastructure, so it is worth stating what works: Fly's edge terminates TLS and forwards to nginx on port 80; media flows over UDP 10000 to the video bridge on a dedicated IPv4, with JVB_ADVERTISE_IPS telling the bridge what address to put in its candidates; and if a restrictive network blocks UDP, Jitsi falls back to TCP 443, which is fine for small infrequent calls.

Deployment follows the same config-driven, deploy-from-admin pattern as the integration worker. deploy-jitsi.ts reads the Video Meetings config from the database, ensures the Fly app and its dedicated IPv4 exist, injects the secrets and host-specific values (PUBLIC_URL, JVB_ADVERTISE_IPS, the JWT secret, the jicofo and jvb component passwords), then validates and deploys with flyctl and scales the memory. The staging pipeline runs it automatically — but only when image-affecting files have changed, detected by diffing against a jitsi-deployed ref, so an ordinary site push never rebuilds the video server.

flowchart TB
    ADMIN@{ icon: "ngx:user", label: "Admin<br/>Global Settings → Video Meetings", pos: "b", h: 48 }
    GHA@{ icon: "logos:github-actions", label: "Deploy workflow<br/>runs only on Jitsi changes", pos: "b", h: 48 }
    DEPLOY@{ icon: "logos:nodejs-icon", label: "deploy-jitsi.ts<br/>config + secrets from DB", pos: "b", h: 48 }

    subgraph app["ngx-ramblers-jitsi — one Fly machine"]
        NGINX["nginx web<br/>Fly edge TLS → :80"]
        PROSODY["prosody<br/>XMPP signalling · JWT auth"]
        JICOFO["jicofo<br/>conference focus"]
        JVB["jvb video bridge<br/>UDP 10000 · TCP 443 fallback"]
    end

    ADMIN --> GHA
    GHA --> DEPLOY
    DEPLOY -->|"flyctl deploy<br/>dedicated IPv4"| app
    NGINX --- PROSODY
    PROSODY --- JICOFO
    JICOFO --- JVB

    style app fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

Notes and AI minutes

Every meeting room has a shared notes panel. Notes persist to a meetingNotes collection tagged with their author, so they survive a reload and are there when the minutes are written. On top of that sits an AI minute-writer: given the call's live transcript, the chat, and any hand-written notes, it drafts structured minutes — and the hand-written notes are folded in rather than overwritten. The AI output is saved as a single note authored "AI notes"; writing again replaces that note instead of stacking duplicates, so the room always holds one current set of minutes plus the members' own words.

The generation itself runs on the estate's integration worker when one is configured, keeping model calls off the site servers; without a worker it falls back to in-process generation. The worker suspends when idle — zero running machines between jobs — which keeps the AI capability effectively free until a meeting actually uses it.

What this adds up to

A committee member plans a meeting on the calendar they already use, invitees get a real calendar invite from an address they recognise, everyone joins in a browser with no account, the committee moderates by virtue of being the committee, notes and minutes come out the other end — and the whole estate's video traffic runs through one small container on hardware we control, with nothing recorded and no third party in the middle. The public-Jitsi default means a brand-new group has working video meetings before any of the self-hosting is set up; the self-hosted mode is where the feature is at its best.

The user-facing walkthrough, with screenshots of planning, booking and cancelling, is in the release note.