Free video meetings from The Fertile Soil
Free for our community
Free video and audio meetings
Run on our own servers, not a big commercial service. Every meeting comes with a written transcript and an AI summary, with no accounts or downloads for guests.
What you get
Simple limits that keep a small server fast for everyone.
Two video sessions
Two simultaneous streams are available, for up to 8 people each. Up to 90 minutes per session. You get a warning 15 minutes before time is up, a second warning at 5 minutes, and a countdown for the last 30 seconds.
Audio-only sessions
Up to four at once, in rooms named audio-1 through audio-4 (three while both video sessions are running).
Live availability
Our meetings page shows what is open right now. Schedule a meeting for any available timeslot up to 3 months out.
Easy for guests
Everyone invited gets an email with a link. Click the link, join the meeting. No additional software to install.
Scheduling
Reserve a slot up to 3 months out. Everyone gets an email with a link and a calendar file. The host can add and remove invitees at will.
Outside the USA
Our meeting servers are hosted outside of the United States.
Recorded, transcribed, and summarized
Recording starts automatically, so nobody has to remember to press a button.
Transcript
The recording becomes text, with each voice labeled as a separate speaker.
AI summary
Main points, decisions, and action items, written by our own privately hosted AI software. No data leaves our servers.
Private link
Everyone invited gets an email with a private link to the summary and transcript. Please do not share it.
Kept for 30 days
Recordings are deleted automatically after 30 days unless the host saves the meeting. The host can delete a recording at any time.
How to use it
Join
Open the link your host sent you.
2. Turn off content blockers for meet.thefertilesoil.org.
3. Allow camera and microphone when asked.
How we keep it running
A few simple habits that keep the service dependable.
Nightly backups
We back up the meeting server’s settings every night to separate storage.
Fixed limits
Each meeting has a fixed size and length so the service stays fast for everyone.
Always watched
We get an email alert if the meeting service ever stops responding.
Like everything we offer, this is free for our members.
If you find it useful, please tell us how you are using it.
For System Administrators: What We Run and How to Configure It
This section is for the person who will build the same service for their own organization. It describes exactly what we run, with the real settings, so you can reproduce it. Everything here is open source, and nothing sends your meetings to a third party.BEFORE YOU START: REPLACE meet.example.org EVERYWHERE
Every example in this guide uses meet.example.org as the hostname. Replace it with your own hostname in every command, config file, and prompt below.
Also replace every placeholder in capital letters (such as PUT_A_LONG_RANDOM_SECRET_HERE and SET_THIS_YOURSELF) with your own values. Never paste real passwords, tokens, or API keys into a chat or a ticket.
The Parts, in One Paragraph
Video meetings run on Jitsi Meet in Docker (docker-jitsi-meet, release stable-11248): Prosody (XMPP), Jicofo (conference focus), the Jitsi Videobridge (media), the web front end, and five Jibri recorders. A small FastAPI web app handles the start page, the signed host links, scheduling and invitation emails, the host’s meeting page (add or remove invitees, share files), the live availability box, the review pages and the admin pages. Recordings are moved to a remote storage volume mounted over SSHFS. A pipeline of systemd timers then transcribes each recording with faster-whisper, separates speakers with sherpa-onnx, summarizes with a local LLM served by Ollama (qwen2.5:7b), and emails each invited person a private link. nginx terminates HTTPS in front of everything.
- Server: one Linux virtual server, 8 vCPU, 16 GB RAM, 300 GB disk, Ubuntu LTS, Docker. CPU only, no GPU. Up to five recordings can run at the same time, and transcription runs at a fraction of real time, so summaries arrive after a meeting ends, not during it.
- Storage: a remote storage volume reachable over SSH (any SFTP-capable storage works). Recordings and transcripts live there, not on the server’s disk.
- Mail: an authenticated SMTP submission account (port 587, STARTTLS) on your existing mail server. Set SPF, DKIM and DMARC for the sending domain or the review emails will land in spam.
- DNS: one A record for meet.example.org pointing to the server. Use a proxy-free (DNS only) record, because the media bridge needs UDP 10000 directly.
1. Prepare the Server
Open only what is needed. Media travels over UDP 10000. Everything else goes through nginx on 443.ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 10000/udp
ufw enable
apt update
apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx \
sshfs ffmpeg python3-venv git linux-modules-extra-$(uname -r)
2. Give the Recorders a Virtual Sound Card
Jibri records by running a real browser inside a container, and it captures that browser’s audio through the ALSA loopback driver. The host kernel must provide it. Each recorder needs its own loopback sub-device, so we create five loopback cards and give each recorder its own numbers.# /etc/modules-load.d/snd-aloop.conf
snd-aloop
# /etc/modprobe.d/snd-aloop.conf
options snd-aloop enable=1,1,1,1,1 index=0,1,2,3,4
# apply now and confirm
modprobe snd-aloop enable=1,1,1,1,1 index=0,1,2,3,4
aplay -l | grep -i loopback
This needs a normal kernel with the snd-aloop module. Some minimal cloud kernels do not ship it. Check before you buy or build the server: modinfo snd-aloop must succeed.
3. Install Jitsi in Docker
Download thestable-11248 release of docker-jitsi-meet to /opt/jitsi, copy env.example to .env, run ./gen-passwords.sh, and create the config folder. These are the settings we changed from the defaults (secrets omitted):
CONFIG=/opt/jitsi-cfg
HTTP_PORT=8000
HTTPS_PORT=8443
DISABLE_HTTPS=1
ENABLE_HTTP_REDIRECT=0
TZ=UTC
PUBLIC_URL=https://meet.example.org
JVB_ADVERTISE_IPS=YOUR.SERVER.IP
# Only people holding a signed host link can create a room; everyone else joins as a guest
ENABLE_AUTH=1
ENABLE_GUESTS=1
AUTH_TYPE=jwt
JWT_APP_ID=tfs
JWT_APP_SECRET=PUT_A_LONG_RANDOM_SECRET_HERE
JWT_ACCEPTED_ISSUERS=tfs
JWT_ACCEPTED_AUDIENCES=tfs
ENABLE_LOBBY=1
# Our custom Prosody modules (section 5)
XMPP_MUC_MODULES=muc_max_occupants,tfs_time_limit,tfs_autorecord,tfs_state
# Server-side recording through Jibri
ENABLE_RECORDING=1
JIBRI_RECORDING_DIR=/config/recordings
JIBRI_FINALIZE_RECORDING_SCRIPT_PATH=/config/finalize.sh
JIBRI_RECORDING_RESOLUTION=1280x720
JIBRI_RECORDING_FRAMERATE=15
JIBRI_RECORDING_CONSTANT_RATE_FACTOR=30
Bind the web container to localhost only, so nginx is the one public entrance. The stock jibri recorder comes from Jitsi’s own jibri.yml and is limited by the override below. We then add four copies (jibri1 to jibri4), which makes five recorders in all. Jitsi’s own jibri.yml is included when you start with -f docker-compose.yml -f jibri.yml (or list it in COMPOSE_FILE).
# /opt/jitsi/docker-compose.override.yml
services:
web:
ports: !override
- "127.0.0.1:8000:8000"
jibri:
cap_add: [SYS_ADMIN, NET_BIND_SERVICE]
devices: ["/dev/snd:/dev/snd"]
mem_limit: 2500m
cpus: 2
jibri1:
extends: {file: jibri.yml, service: jibri}
container_name: jitsi-jibri1
cap_add: [SYS_ADMIN, NET_BIND_SERVICE]
devices: ["/dev/snd:/dev/snd"]
mem_limit: 2500m
cpus: 2
environment: [JIBRI_INSTANCE_ID=tfs-jibri-1]
volumes:
- /opt/jitsi-cfg/jibri1:/config:Z
- /opt/jitsi-cfg/storage/jibri1:/storage:Z
- /opt/jitsi-cfg/jibri1/asoundrc:/home/s6/.asoundrc:ro
jibri2:
extends: {file: jibri.yml, service: jibri}
container_name: jitsi-jibri2
cap_add: [SYS_ADMIN, NET_BIND_SERVICE]
devices: ["/dev/snd:/dev/snd"]
mem_limit: 2500m
cpus: 2
environment: [JIBRI_INSTANCE_ID=tfs-jibri-2]
volumes:
- /opt/jitsi-cfg/jibri2:/config:Z
- /opt/jitsi-cfg/storage/jibri2:/storage:Z
- /opt/jitsi-cfg/jibri2/asoundrc:/home/s6/.asoundrc:ro
jibri3:
extends: {file: jibri.yml, service: jibri}
container_name: jitsi-jibri3
cap_add: [SYS_ADMIN, NET_BIND_SERVICE]
devices: ["/dev/snd:/dev/snd"]
mem_limit: 2500m
cpus: 2
environment: [JIBRI_INSTANCE_ID=tfs-jibri-3]
volumes:
- /opt/jitsi-cfg/jibri3:/config:Z
- /opt/jitsi-cfg/storage/jibri3:/storage:Z
- /opt/jitsi-cfg/jibri3/asoundrc:/home/s6/.asoundrc:ro
jibri4:
extends: {file: jibri.yml, service: jibri}
container_name: jitsi-jibri4
cap_add: [SYS_ADMIN, NET_BIND_SERVICE]
devices: ["/dev/snd:/dev/snd"]
mem_limit: 2500m
cpus: 2
environment: [JIBRI_INSTANCE_ID=tfs-jibri-4]
volumes:
- /opt/jitsi-cfg/jibri4:/config:Z
- /opt/jitsi-cfg/storage/jibri4:/storage:Z
- /opt/jitsi-cfg/jibri4/asoundrc:/home/s6/.asoundrc:ro
Each recorder gets its own folder under /opt/jitsi-cfg containing asoundrc, finalize.sh and an empty recordings directory. The finalize script only drops a marker file so the ingest job knows the recording is complete:
#!/bin/bash
# /opt/jitsi-cfg/jibriN/finalize.sh (args: recording directory)
touch "$1/.done"
The only part that differs between recorders is the sound-card wiring. Recorder N uses sub-device N on the first loopback card, and its own ipc_key values (ours are 219345 to 219348 for recorder 1, 229345 to 229348 for recorder 2, and so on). Two recorders must never share a sub-device or an ipc_key, or their audio will mix.
# /opt/jitsi-cfg/jibriN/asoundrc
# Replace N with the recorder number (1 to 4) everywhere it appears.
# The ipc_key values then differ per recorder: 219345 for recorder 1, 229345 for recorder 2, and so on.
pcm.amix {
type dmix
ipc_key 2N9345
slave.pcm "hw:Loopback,0,N"
}
pcm.asnoop {
type dsnoop
ipc_key 2N9346
slave.pcm "hw:Loopback_1,1,0"
}
pcm.aduplex {
type asym
playback.pcm "amix"
capture.pcm "asnoop"
}
pcm.bmix {
type dmix
ipc_key 2N9347
slave.pcm "hw:Loopback_1,0,0"
}
pcm.bsnoop {
type dsnoop
ipc_key 2N9348
slave.pcm "hw:Loopback,1,N"
}
pcm.bduplex {
type asym
playback.pcm "bmix"
capture.pcm "bsnoop"
}
pcm.pjsua {
type plug
slave.pcm "bduplex"
}
pcm.!default {
type plug
slave.pcm "aduplex"
}
Start the stack, then check that every recorder registered with Jicofo before you test a meeting:
cd /opt/jitsi
docker compose up -d
docker compose logs jicofo | grep -i "jibri" # expect five available instances (the stock recorder plus jibri1 to jibri4)
4. Custom Branding
Jitsi reads/opt/jitsi-cfg/web/custom-interface_config.js and appends it to its interface settings. We use it to replace every visible Jitsi name and logo:
interfaceConfig.APP_NAME = 'TFS Meetings';
interfaceConfig.NATIVE_APP_NAME = 'TFS Meetings';
interfaceConfig.PROVIDER_NAME = 'The Fertile Soil';
interfaceConfig.JITSI_WATERMARK_LINK = 'https://thefertilesoil.org/meetings/';
interfaceConfig.SHOW_JITSI_WATERMARK = true;
interfaceConfig.DEFAULT_LOGO_URL = 'images/tfs-logo.png';
interfaceConfig.DEFAULT_BACKGROUND = '#1f2a1a';
interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE = false;
interfaceConfig.DISPLAY_WELCOME_FOOTER = false;
interfaceConfig.SHOW_CHROME_EXTENSION_BANNER = false;
Some visible text and images are baked into pages, so nginx also rewrites them on the way out (see section 6): the words “Jitsi Meet” become your name, the stock logo files are served from your own logo, and one small script, /tfs-notice.js, is injected into every Jitsi page. That script does five jobs: it shows the “all meetings are recorded” notice before joining and a five-second banner after joining, shows your photo backgrounds on the join screen, replaces the machine-made room code with the meeting’s real name, runs the meeting clock (a warning at 15 minutes left, a second warning at 5 minutes, a large countdown for the last 30 seconds, and an “add 30 minutes” button for the host), and rewrites the closing “thank you” message. It is plain JavaScript with no libraries, so it is easy to adapt.
5. Three Small Prosody Modules
Prosody is Jitsi’s chat server, and it runs a room for every meeting. We add three small Lua modules that live in/opt/jitsi-cfg/prosody/prosody-plugins-custom/ and are switched on by the XMPP_MUC_MODULES line above. Their limits are set in /opt/jitsi-cfg/prosody/config/conf.d/00-tfs-limits.cfg.lua:
-- cap room size at 8 people; the Jicofo focus user is not counted
muc_max_occupants = 8
muc_access_whitelist = { "auth.meet.jitsi" }
tfs_meeting_limit_seconds = 5400
tfs_meeting_warn_seconds = 5100
Module 1, automatic recording. When the first person joins a room, wait six seconds and ask Jibri to start recording. Two details cost us hours, so they are worth knowing: the request has to be sent from the occupant’s own room address (occupant.nick) or Jicofo ignores it, and with guests enabled people appear under a second domain (guest.meet.jitsi), so the check must accept both.
-- mod_tfs_autorecord.lua
local st = require "util.stanza";
local jid_split = require "util.jid".split;
local ENABLED = module:get_option_boolean("tfs_autorecord_enabled", true);
local DELAY = module:get_option_number("tfs_autorecord_delay_seconds", 6);
local MAIN_DOMAIN = module:get_option_string("muc_mapper_domain_base", "meet.jitsi");
local FOCUS = "focus@auth." .. MAIN_DOMAIN .. "/focus";
local muc_service = prosody.hosts[module.host].modules.muc;
local function on_joined(event)
if not ENABLED then return; end
local room, occupant = event.room, event.occupant;
if room._tfs_autorecord then return; end
if string.find(room.jid, "breakout", 1, true) then return; end
local bare = occupant.bare_jid;
local _, host = jid_split(bare);
if host ~= MAIN_DOMAIN and host ~= ("guest." .. MAIN_DOMAIN) then return; end
room._tfs_autorecord = true;
module:log("info", "TFS autorecord: %s joined %s, starting recording in %d s", bare, room.jid, DELAY);
module:add_timer(DELAY, function()
if muc_service.get_room_from_jid(room.jid) ~= room then return; end
local iq = st.iq({ type = "set", from = occupant.nick, to = FOCUS, id = "tfs-rec-" .. tostring(os.time()) });
iq:tag("jibri", {
xmlns = "http://jitsi.org/protocol/jibri",
action = "start",
recording_mode = "file",
app_data = '{"file_recording_metadata":{"share":false}}',
});
module:send(iq);
end);
end
module:hook("muc-occupant-joined", on_joined, -1);
Module 2, the meeting clock. Every room has a time limit. Walk-in meetings start at 30 minutes and the host can add 30 minutes at a time, up to 90. The web app writes the current limit for each room to /config/tfs-limits.json (for example {"tfs-ab12cd": {"minutes": 30}}), and this module closes the room when its time is up. Rooms with no entry, such as scheduled meetings, get the full 90 minutes. The warnings, the countdown and the “add 30 minutes” prompt are drawn in the browser, so this module only has to enforce the end.
-- mod_tfs_time_limit.lua
local json = require "util.json";
local is_healthcheck_room = module:require "util".is_healthcheck_room;
local DEFAULT_MIN = module:get_option_number("tfs_meeting_limit_seconds", 5400) / 60;
local LIMITS = "/config/tfs-limits.json";
local muc_service = prosody.hosts[module.host].modules.muc;
local function read_limits()
local f = io.open(LIMITS, "r");
if not f then return {}; end
local txt = f:read("*a");
f:close();
local ok, d = pcall(json.decode, txt);
if ok and type(d) == "table" then return d; end
return {};
end
local function on_room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return; end
room._tfs_started = os.time();
end
local function sweep()
local ok, err = pcall(function()
local limits = read_limits();
local now = os.time();
local rooms = muc_service.live_rooms and muc_service.live_rooms() or muc_service.each_room();
local to_close = {};
for room in rooms do
if not is_healthcheck_room(room.jid) then
local name = room.jid:match("^([^@]+)@");
room._tfs_started = room._tfs_started or now;
local e = limits[name];
local minutes = (e and tonumber(e.minutes)) or DEFAULT_MIN;
if now - room._tfs_started >= minutes * 60 then
to_close[#to_close + 1] = room;
end
end
end
for _, room in ipairs(to_close) do
room:destroy(nil, "This meeting reached its time limit.");
end
end);
if not ok then module:log("warn", "tfs_time_limit sweep failed: %s", tostring(err)); end
end
module:hook("muc-room-created", on_room_created, -1);
module:add_timer(10, function() sweep(); return 10; end);
Module 3, room state for the web app. Every 20 seconds, and whenever someone joins or leaves, it writes the list of active rooms to /config/tfs-rooms.json. The web app reads that file to show “video session available” and to run the countdown. It never talks to Prosody directly.
-- mod_tfs_state.lua
local muc_service = prosody.hosts[module.host].modules.muc;
local OUT = "/config/tfs-rooms.json";
local function write_state()
local ok, err = pcall(function()
local parts = {};
local rooms = muc_service.live_rooms and muc_service.live_rooms() or muc_service.each_room();
for room in rooms do
local name = room.jid:match("^([^@]+)@");
local n = 0;
for _, occ in room:each_occupant() do
local host = occ.bare_jid and occ.bare_jid:match("@(.+)$") or "";
if host == "meet.jitsi" or host == "guest.meet.jitsi" then n = n + 1; end
end
if name and n > 0 then
room._tfs_started = room._tfs_started or os.time();
parts[#parts + 1] = string.format('{"room":"%s","occupants":%d,"started":%d}', name, n, room._tfs_started);
end
end
local f = io.open(OUT .. ".tmp", "w");
if f then
f:write('{"updated":' .. os.time() .. ',"rooms":[' .. table.concat(parts, ",") .. ']}');
f:close();
os.rename(OUT .. ".tmp", OUT);
end
end);
if not ok then module:log("warn", "tfs_state write failed: %s", tostring(err)); end
end
local function later() module:add_timer(1, function() write_state(); end); end
module:hook("muc-occupant-joined", later, -2);
module:hook("muc-occupant-left", later, -2);
module:hook("muc-room-destroyed", later, -2);
module:add_timer(5, function() write_state(); return 20; end);
After you change a module or the .env, restart in this order and wait for each to settle: docker restart jitsi-prosody-1, then jitsi-jicofo-1, then jitsi-jvb-1. If Prosody restarts alone, the bridge can silently drop off Jicofo’s list and meetings will join but have no audio or video.
6. nginx
nginx terminates HTTPS (get the certificate withcertbot --nginx -d meet.example.org), serves your static start page, sends the app’s paths to FastAPI on 127.0.0.1:8100, and proxies everything else to Jitsi. The sub_filter lines are how we rebrand Jitsi without editing its container. Note that Accept-Encoding is cleared so nginx can read and rewrite the page.
map $http_upgrade $connection_upgrade { default upgrade; "" close; }
server {
server_name meet.example.org;
client_max_body_size 3g;
# admin pages, protected by a password file (htpasswd -c /etc/nginx/tfs/review.htpasswd admin)
location ^~ /submit/ {
auth_basic "Meetings admin";
auth_basic_user_file /etc/nginx/tfs/review.htpasswd;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# public JSON API: start a meeting, availability, waitlist, scheduling requests, clock, extend
location ^~ /api/ {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 4k;
}
# the host's meeting page and shared files (the token in the URL is the secret)
location ^~ /manage/ {
access_log off;
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 300m;
proxy_read_timeout 600s;
}
location ^~ /files/ {
access_log off;
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
# private review pages, video streaming and PDF (the token in the URL is the secret)
location ^~ /review/ {
access_log off;
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = / {
root /opt/tfs-review/static;
try_files /index.html =404;
add_header Cache-Control no-cache;
}
location ^~ /bg/ { root /opt/tfs-review/static; add_header Access-Control-Allow-Origin "*"; }
location = /tfs-notice.js { root /opt/tfs-review/static; add_header Cache-Control no-cache; }
# replace Jitsi's stock logo files with your own
location = /images/watermark.svg {
alias /opt/tfs-review/static/tfs-logo.png;
default_type image/png;
types { }
}
# everything else is Jitsi, rebranded on the fly
location / {
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html application/json;
sub_filter 'Jitsi Meet' 'Your Meetings';
sub_filter '</head>' '<script src="/tfs-notice.js" defer></script></head>';
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
listen 443 ssl; # certbot adds the certificate lines
}
Copy the same alias block for each stock image you want to replace (jitsilogo.png, logo-deep-linking.png, favicon.svg, and so on). Find the file names in your browser’s network tab.
7. The Web App (FastAPI)
The app lives in/opt/tfs-review in its own Python virtual environment and listens on 127.0.0.1:8100 only. Install it with python3 -m venv venv && venv/bin/pip install fastapi uvicorn reportlab faster-whisper sherpa-onnx numpy and run it as a systemd service:
# /etc/systemd/system/tfs-review.service
[Unit]
Description=TFS meeting web app
After=network.target mnt-tfs\x2dmeetings.mount
[Service]
WorkingDirectory=/opt/tfs-review
ExecStart=/opt/tfs-review/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8100 --no-access-log
Restart=on-failure
MemoryMax=600M
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
These are the routes it serves:
POST /api/start: the “Start a meeting” button. Takes a name, an email, video or audio-only, an optional meeting name and up to seven invitee emails. It checks capacity, picks a room, saves who the host is and who to email afterwards, sets the room’s clock to 30 minutes, emails the invitees the meeting link, and returns a Jitsi link carrying a signed host token.GET /api/availability: the live “video session open / audio rooms free” box, read fromtfs-rooms.jsonand the schedule.POST /api/waitlist: “tell me when a room is free” emails.GET /api/room-clockandPOST /api/extend: the countdown, and the host’s “add 30 minutes” button, which works only in the last 15 minutes and only up to 90 minutes in total (the token proves who the host is).GET /api/room-title: the meeting’s name, so the join screen shows it instead of the room code.GET /review/{token},/video,/download,/pdfandPOST /delete: the private page each participant gets, with the recording, a PDF of the notes, and a delete button for the host only./submit/*: the admin pages (list, keep, delete, schedule, upload, retention setting). nginx protects these with a password.
AUTH_TYPE=jwt, Jitsi will not create a room unless the visitor presents a token signed with the shared secret. The app is the only place that can sign one, and it only does so after the capacity checks pass. The token is valid for one room and three hours. Everyone else joins as a guest and waits in the room until the host arrives.
import base64, hashlib, hmac, json, time
def b64(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def make_jwt(secret: str, room: str, name: str) -> str:
now = int(time.time())
payload = {
"iss": "tfs", "aud": "tfs", "sub": "*", "room": room,
"iat": now, "nbf": now - 30, "exp": now + 3 * 3600,
"moderator": True,
"context": {"user": {"name": name, "moderator": True, "affiliation": "owner"}},
}
head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
body = b64(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(secret.encode(), (head + "." + body).encode(), hashlib.sha256).digest()
return head + "." + body + "." + b64(sig)
# the link the visitor is sent to: https://meet.example.org/ROOM?jwt=TOKEN
Keep the secret in a file readable only by root (we use /opt/tfs-review/jwt.json with mode 600) and the same value in JWT_APP_SECRET. Do not put the person’s email address in the token, because Jitsi uses it to fetch a profile picture from an outside service.
Capacity rules we enforce in /api/start. Two video sessions at a time (a room is held for two minutes while the host connects, so two people cannot grab the last one at once), four audio-only rooms named audio-1 to audio-4 (only three while both video sessions run, because there are five recorders in all), at most eight people per room (Prosody enforces that), at most six starts per visitor per hour (behind nginx, read the visitor’s address from X-Forwarded-For and make sure nginx sets that header, or the limit will count everyone as one person), and scheduled meetings reserve up to 90 minutes that walk-ins cannot overlap, can be booked up to about three months ahead, and open 15 minutes early.
The record kept for each recording. Next to every recording sits a small JSON file. It lists the title, the status (uploaded, transcribing, transcribed, summarizing, done, or failed), the host’s email, the people to notify, a random 43-character token for each of them, the summary and transcript file names, and whether the meeting was “kept” (never auto-deleted). Their private page is /review/<token>. A person’s token only lets them view that one meeting, and the host’s token additionally allows delete.
8. From Recording to Emailed Notes
Four timers do the work. All are plain systemd timers that run a Python script from the virtual environment.tfs-ingest(every 30 seconds): finds finished recorder folders (those with the.donemarker), copies the video to the storage volume, writes its JSON record including who to notify, and deletes the local copy. The server’s own disk never keeps a recording.tfs-process(every minute): picks up new records. If the worker is ever killed part-way (for example by its memory limit), the next run puts the job back in the queue and tries again, up to three times. It extracts mono 16 kHz audio withffmpeg, transcribes it, separates speakers, writes the summary, creates each person’s review token, and sends the emails.tfs-quiet-watch(every 10 seconds): notices when a meeting is running and tells the transcription worker to wait, so transcription never competes with a live call. It resumes when the last meeting ends.tfs-purge(03:30 UTC nightly): deletes unsaved meetings older than the retention setting (we use 30 days).tfs-backup(nightly at 04:10 UTC) andtfs-waitlist(every minute): a nightly archive of settings and code to the storage volume, and the “a room is free now” emails.
# /etc/systemd/system/tfs-process.service
[Unit]
Description=Transcription worker for new meeting recordings
[Service]
Type=oneshot
WorkingDirectory=/opt/tfs-review
ExecStart=/opt/tfs-review/venv/bin/python /opt/tfs-review/process.py
Nice=15
# a 50-minute meeting used about 6 GB while transcribing, so 6000M was too low
MemoryMax=9500M
TimeoutStartSec=12h
# /etc/systemd/system/tfs-process.timer
[Unit]
Description=Check for new TFS meeting recordings every minute
[Timer]
OnBootSec=2min
OnUnitInactiveSec=60s
AccuracySec=10s
[Install]
WantedBy=timers.target
The three AI steps all run on the CPU of the same server, with nothing sent outside:
# 1. transcription (faster-whisper, medium model, 8-bit on CPU)
from faster_whisper import WhisperModel
model = WhisperModel("medium", device="cpu", compute_type="int8", cpu_threads=5,
download_root="/opt/tfs-review/models")
segments, info = model.transcribe(audio, vad_filter=True, beam_size=1)
# 2. who spoke when (sherpa-onnx: pyannote segmentation + a speaker-embedding model)
config = sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(model="segmentation/model.onnx"),
num_threads=4),
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(model="emb.onnx", num_threads=4),
clustering=sherpa_onnx.FastClusteringConfig(num_clusters=-1, threshold=0.6),
min_duration_on=0.3, min_duration_off=0.5)
# 3. summary (Ollama, local model qwen2.5:7b)
body = {"model": "qwen2.5:7b", "prompt": prompt, "stream": False,
"options": {"temperature": 0.2, "num_ctx": 8192, "num_predict": 900}}
# POST it to http://127.0.0.1:11434/api/generate
Run Ollama in Docker with a persistent volume (ollama pull qwen2.5:7b once). Tell the model plainly to summarize only what was said, to list decisions and action items, and to say so when there was too little speech. A short meeting with almost no talking should produce “too little speech to summarize” and not an invented summary. Speaker names are not known to the system, so speakers appear as Speaker 1, Speaker 2, and so on unless the transcript contains clear spoken introductions.
Mail. The app sends through your own mail server with an authenticated submission account:
# /opt/tfs-review/mail.json (chmod 600)
{"host": "mail.example.org", "port": 587, "user": "mailer@example.org", "password": "SET_THIS_YOURSELF"}
9. Storage, Retention and Backups
Mount the remote storage volume at/mnt/tfs-meetings with SSHFS and link /opt/tfs-review/uploads to it. Use a dedicated SSH key and a sub-account that can only see its own folder.
# /etc/fstab (one line)
SUBUSER@STORAGEHOST:uploads /mnt/tfs-meetings fuse.sshfs _netdev,port=23,IdentityFile=/root/.ssh/storagebox_ed25519,allow_other,reconnect,ServerAliveInterval=15,x-systemd.automount,uid=0,gid=0 0 0
Each recording, its transcript, its summary and its record file share a file-name prefix, so deleting a meeting is one glob. The review page streams video straight from the mount using HTTP range requests, so nothing is copied back to the server. The nightly backup archives /opt/jitsi, /opt/jitsi-cfg, /opt/tfs-review, the nginx and systemd files and /etc/fstab to the same storage volume and keeps the newest 14. Because that archive contains your secrets, give the backup sub-account a different, restricted key.
10. Testing Checklist
- Start a meeting from the start page on a phone and a laptop. The host should land in the room as moderator with no login.
- Join as a guest in a private window. You should see the recording notice before joining and the five-second banner after.
- Confirm the recorder started:
docker logs jitsi-prosody-1 | grep autorecordshould show “start request sent”. - End the meeting. Within a few minutes
/opt/tfs-review/uploadsshould hold the video, then the transcript and summary, and the invited email addresses should receive the review link. - On the review page, play the video, download it, save the PDF, and (as the host) delete it. Confirm the other person’s link no longer works.
- Shorten a test room’s limit in
tfs-limits.jsonto about twenty minutes and confirm the 15-minute warning, the 5-minute warning, the final 30-second countdown and the “add 30 minutes” prompt appear for the host, and that guests see the warnings but not the button.
Things That Will Bite You
- The recorder needs the
snd-aloopkernel module and theSYS_ADMINcapability. Without them recordings start and produce silent or empty files. - Behind nginx, Jitsi’s WebSocket needs the
Upgradeheaders and a longproxy_read_timeout, or people are disconnected every minute. - Test with real browsers. Headless test tools in some cloud environments cannot hold the media connection, so a join can look stuck when the server is fine.
- Setting a room’s limit to a tiny number to test it will also close the room on whoever is inside. Test with a spare room.
- Recording is on for every room. Tell people before they join (we do, in two places), and check what your local rules require for consent notices.
Build It With Claude Cowork: The Prompts to Use
You do not have to type the configuration above by hand. If you use Claude Cowork (the Claude desktop app with a linked computer), you can have Claude build and test the whole service for you, one stage at a time. These are the prompts that work. Paste them in order, wait for Claude to finish and show its evidence at each step, and only then move on.Before You Start
- A server you can reach by SSH as root, running a recent Ubuntu LTS, with at least 8 vCPUs, 16 GB of RAM, and a normal (not minimal) kernel. Add your SSH public key to it and make sure you can log in from the computer linked to Claude.
- A hostname such as meet.example.org, with a DNS-only A record pointing to the server. Create it yourself at your DNS provider.
- Remote storage reachable over SSH or SFTP for recordings, plus a key for it.
- A mail account your server can send from (host, port 587, username). You enter the password into a file on the server yourself. Never paste passwords, API keys or tokens into the chat.
- Your own words for the notices. Decide what your organization tells people about recording, how long you keep recordings, and where the data lives. Claude can draft them, but you are responsible for them.
Prompt 0: Set the Ground Rules
I want you to build a self-hosted, open-source alternative to Zoom for my organization, one stage at a time. The final service must: run Jitsi Meet in Docker at https://meet.example.org; let anyone start a meeting from a start page by typing their name and email (no accounts); record every meeting on the server; transcribe it, separate speakers, and write a summary using only software running on our own server; email each invited person a private link to the notes, the video and a PDF; keep recordings on remote storage and delete unsaved ones after 30 days; and give me an admin page to see and delete recordings.
Ground rules for this whole project:
1. Work on the server over SSH. Before you change anything, tell me what you are about to do. Back up any file before you edit it.
2. Never ask me to paste a password, key or token in this chat. When a secret is needed, create an empty file with the right permissions and tell me exactly what to put in it.
3. After every stage, prove it works with a real test and show me the evidence (command output or a screenshot). If something fails, find the cause before trying something else.
4. Do not delete data or restart services that are in use without asking me first.
5. Write code with plain comments, no clever tricks, and keep a short log of what you changed in /root/BUILD-NOTES.md so the next administrator can follow it.
Start by asking me for the server address, the hostname, and the storage details. Then check the server (OS, CPU, RAM, disk, kernel) and tell me if anything is a problem before we begin.
Prompt 1: Prepare the Server
Stage 1: prepare the server. Install Docker with the compose plugin, nginx, certbot, sshfs, ffmpeg and Python venv support. Set up a firewall that allows only 22, 80, 443 over TCP and 10000 over UDP. Check that the kernel provides the snd-aloop sound module (modinfo snd-aloop); if it does not, stop and tell me, because recording will not work without it. Create five loopback sound cards with options "enable=1,1,1,1,1 index=0,1,2,3,4", make them load at boot, and show me aplay -l proving they exist. Then get a TLS certificate for meet.example.org with certbot once my DNS record resolves to this server.
Prompt 2: Install Jitsi
Stage 2: install Jitsi Meet from docker-jitsi-meet release stable-11248 in /opt/jitsi, with its data in /opt/jitsi-cfg. Generate the internal passwords. Bind the web container to 127.0.0.1:8000 only, put nginx in front of it on 443 with proper WebSocket upgrade headers and long read timeouts, and set PUBLIC_URL and JVB_ADVERTISE_IPS correctly. Turn on the lobby. Cap rooms at eight people with Prosody's muc_max_occupants. Do not enable recording yet. Prove it works by opening the site in a browser and holding a two-person test call with audio and video. Open the browser yourself if you can; if the cloud sandbox cannot hold the media connection, ask me to join from my own phone or laptop and read the server logs to confirm.
Prompt 3: Recording That Starts by Itself
Stage 3: server-side recording. Add five Jibri recorders: the stock jibri service plus four more containers (jibri1 to jibri4), each of the four with its own config folder under /opt/jitsi-cfg, its own asoundrc using a different loopback sub-device and different ipc_key values, SYS_ADMIN capability, /dev/snd, 2.5 GB memory and 2 CPUs. Use a finalize script that only creates a .done file in the recording folder. Confirm all four register with Jicofo. Then write a small Prosody module that starts a recording automatically six seconds after the first person joins a room. Pitfalls you must handle: the start request has to be sent from the occupant's own room address (occupant.nick), and people may appear under a "guest." second domain, so accept both. Prove it by joining a room and then showing me the finished MP4 with a real duration and audible audio (use ffprobe and a volume check).
Prompt 4: Host Links With No Login
Stage 4: start a meeting with no account. Switch Jitsi to JWT authentication with guests allowed, so only a signed token can create a room and everyone else joins as a guest. Build a small FastAPI app in /opt/tfs-review (own virtualenv, systemd service, listening on 127.0.0.1:8100 only) with a POST /api/start endpoint that takes name, email, video or audio-only, an optional meeting name and up to seven invitee emails, and returns a Jitsi link carrying an HS256 token valid for that one room for three hours. Keep the signing secret in a root-only file, never in the code. Rules: two video sessions at a time (hold a freshly issued room for two minutes), four audio-only rooms audio-1 to audio-4 (only three while both video sessions run), at most six starts per visitor per hour using X-Forwarded-For that nginx sets itself, and no email address inside the token. Then build a clean start page served by nginx at the site root, with a name field, an email field, a Video or Audio-only choice, an optional meeting name, and a field for up to seven extra emails. Test it from a phone and a laptop.
Prompt 5: Time Limits, Notices and Branding
Stage 5: policies and branding. (a) Meeting clock: walk-in meetings start at 30 minutes and the host can add 30 minutes at a time up to 90. Store each room's limit in a JSON file that a Prosody module reads every ten seconds and enforces; add API routes for the current clock and for extending (extending must require the host's token). In the browser, warn everyone 15 minutes before the end (and offer the host a button to add 30 minutes), warn again at 5 minutes, and show a large countdown for the last 30 seconds. (b) Recording notices: show a prominent notice on the join screen that all meetings are recorded, placed at the bottom so it never covers the login or name fields on a phone, and a five-second banner after joining. (c) Rebrand: replace every visible Jitsi name and logo with ours, and show the meeting's real name instead of the random room code. Use an nginx sub_filter plus one injected JavaScript file, and Jitsi's custom-interface_config.js. Test on a narrow phone screen as well as a laptop.
Prompt 6: Notes, Video and PDF
Stage 6: the processing pipeline, all local. Mount the remote storage over SSHFS at /mnt/tfs-meetings (key-based, automount, reconnect), and link /opt/tfs-review/uploads to it. Build timer-driven jobs that (1) move finished recordings from the recorder folders to the storage mount and write a JSON record with the title, host email and invitee emails, then delete the local copy; (2) transcribe with faster-whisper (medium model, int8, CPU), separate speakers with sherpa-onnx, and summarize with Ollama running qwen2.5:7b, instructing the model to use only what was said and to say plainly when there was too little speech; (3) create a random private token for each invited person and email them a link. Build the private review page: summary, full transcript, the video in a player with a download button, a "Save the notes as a PDF" button, and a delete button that appears only for the host. Review links must expire with the meeting. Test with a real two-minute meeting where two people talk, then show me the email, the page, the PDF and the downloaded video.
Prompt 7: Admin, Retention and Backups
Stage 7: administration. Build a password-protected admin page (nginx basic auth) that lists every stored meeting with its date, status, number of people emailed and deletion date, and has buttons to view, download, keep forever and delete now, with a confirmation before delete. Add a nightly purge that deletes unsaved meetings older than the retention setting (30 days by default, changeable on the page), a nightly backup of /opt/jitsi, /opt/jitsi-cfg, /opt/tfs-review, the nginx and systemd files and /etc/fstab to the storage volume (keep the newest 14, exclude the virtualenv and downloaded models), and an alert email if the site stops responding. Show me a dry run of the purge before enabling it.
Prompt 8: Check Everything and Hand It Over
Stage 8: final checks. Run a full end-to-end test as three different people (host on a phone, guest on a laptop, guest in a private window) and report: did the host start without a login, did both notices show, did recording start, did the 15-minute and 5-minute warnings and the final countdown appear when the limit was set to twenty minutes, did each person receive a working link, can the host delete and can a guest not delete. Then review the build for security problems: open ports, file permissions on secrets, anything that is world-readable that should not be, rate limits, and whether any private link or token appears in the nginx access logs. Fix what you find. Finish by updating /root/BUILD-NOTES.md with how to restart each part, where the logs are, how to rotate the signing secret, and how to restore from the backup, and give me a short plain-language summary I can hand to my board.
Tips for Good Results
- One stage at a time. If a stage fails, say “stop, show me the log, and find the cause” before letting Claude try fixes.
- Use real devices to test. Cloud test browsers often cannot hold a video call, so ask Claude to have you join from a phone and then verify from the server logs.
- Keep secrets out of the chat. If Claude asks for a password or a key, tell it to create the file and let you fill it in.
- Write down your own policy. The service records everyone. The notices, retention period and who can delete are decisions for your organization, not the software.
