Jitsi self-hosted vs Zoom: what actually changes
Consumer video conferencing services (Zoom, Google Meet, Teams) impose time limits on free plans, participant quotas, and keep your recordings on their servers. Self-hosted Jitsi Meet removes these constraints: rooms with no cap on participants, no meeting time limit, no account required to join a meeting, and video streams that stay on your infrastructure. This is particularly relevant for an organization concerned about the confidentiality of its meetings, a firm handling sensitive data, or anyone who wants to embed video calling in a third-party application (Jitsi provides a stable iframe API). Running costs come down to the VPS bill — no per-seat subscription, no software surcharge.
Concrete benefits of self-hosted Jitsi
- Meetings with no time limit and no cap on the number of rooms — no timer cutting the call.
- No account required for participants: a link is enough to join.
- iframe API to embed video calling directly in your application or website.
- Transport encryption (DTLS-SRTP) and full control over any recordings.
- Complete customization: logo, colors, room domain name.
- No telemetry sent to a third party — video streams never leave your VPS.
Concrete prerequisites before you start
Jitsi is sized by bandwidth, not CPU. The Jitsi documentation recommends 8 GB of RAM (4 GB is enough for small meetings), around 4 dedicated cores for a basic server, and above all a reliable network link: a 720p stream uses about 2.5 Mbit/s, and a 100 Mbit/s link will not carry twenty participants in high definition. Monitor JVB network load. Three ports must be reachable from the internet: TCP 443 (HTTPS), TCP 80 (Let's Encrypt challenge and redirect to HTTPS), UDP 10000 (JVB media). TCP port 4443, mentioned in older guides, is no longer on the official list. If coturn runs on the same VPS, add its ports: 3478 over UDP and TCP, 5349 for TLS (443 is already taken by the web container). You need Docker with Compose V2 (the docker compose command), a domain name meet.yourdomain.com with an A record pointing to the VPS public IP, and a dedicated, stable public IP — a shared IP makes coturn configuration significantly harder.
Step-by-step deployment: Docker, .env, coturn and first test
Fetch docker-jitsi-meet
Download the latest release from the releases page of
github.com/jitsi/docker-jitsi-meetand unzip it: the official guide advises against cloning the repository, whose main branch pulls theunstableimages. Create the configuration folders listed in the guide (~/.jitsi-meet-cfg/…, includingstorageandtmp, writable by uid 1000): the containers run rootless and refuse to start otherwise. Copyenv.exampleto.env, then run./gen-passwords.sh— this script generates all internal secrets (Prosody, Jicofo, JVB). Do not skip this step: default values are not secrets. Thestable-11248-3release (November 2026) is the latest stable; avoidmasterand theunstableimages in production — internal components change without notice.Set PUBLIC_URL, the ports and JVB_ADVERTISE_IPS
In
.env, first setHTTP_PORT=80andHTTPS_PORT=443(8000 and 8443 by default). Two variables are then critical:PUBLIC_URL=https://meet.yourdomain.com(used by the web client to build room URLs) andJVB_ADVERTISE_IPS=<VPS_PUBLIC_IP>(the address JVB announces to clients to establish the direct media connection; it replaces the formerDOCKER_HOST_ADDRESS). WithoutJVB_ADVERTISE_IPS, JVB tries to discover its public IP via STUN; if the announced address is wrong, calls fail as soon as more than two participants join the meeting.Enable Let's Encrypt in .env
Add to
.env:ENABLE_LETSENCRYPT=1,LETSENCRYPT_DOMAIN=meet.yourdomain.com,[email protected]. Verify that port 80 is open before starting — Let's Encrypt uses an HTTP-01 challenge that requires a response on that port. The web container obtains and renews the certificate with acme.sh (an internal renewal service) and keeps it in~/.jitsi-meet-cfg/storage/web: keep that folder across restarts. Also addLETSENCRYPT_ACME_SERVER=letsencrypt: without it, acme.sh uses ZeroSSL by default.Configure coturn (TURN server)
This is the step most guides skim over and that causes installations to fail. docker-jitsi-meet does not ship coturn: install it separately (package or container), then declare it in
.envso that Prosody hands clients temporary credentials:TURNS_HOST=turn.yourdomain.comandTURNS_PORTfor TLS, plusTURN_HOST=turn.yourdomain.com,TURN_PORT=3478andTURN_TRANSPORT=udp,tcp. Port 443 over TLS crosses the most restrictive firewalls, which block non-standard ports but let through anything resembling HTTPS; it needs an IP the web container does not already use, otherwise keep 5349.Then generate a shared secret (
openssl rand -hex 32) and put it inTURN_CREDENTIALSin.envand in coturn (use-auth-secretandstatic-auth-secret=<the same secret>); neverno-auth, which would turn your server into a relay open to anyone. The TLS certificate is declared in the coturn configuration (cert=andpkey=), not in.env. If coturn runs in a separate container, mount these files as volumes.One often-missed detail: coturn does not block RFC1918 ranges (192.168.x.x, 10.x.x.x, 172.16.x.x) by default: only loopback is refused. An authenticated client can therefore relay traffic into your internal network. Add
no-multicast-peersanddenied-peer-iplines for those ranges, then, if JVB has an internal address, allow only that one withallowed-peer-ip.Start the stack and check the JVB
Run
docker compose up -d. Wait 30 seconds, then:docker compose logs jvb 2>&1 | grep -E 'register|connected|error'. You should see a line showing that JVB registered with Jicofo. If you seeFailed to registerorConnection refused, Prosody is not ready yet — retry after a minute. Then verify the ports respond:nc -zv meet.yourdomain.com 443(web, TCP),nc -zv turn.yourdomain.com 3478(coturn, TCP) andnc -zvu meet.yourdomain.com 10000(JVB, UDP; a UDP success is only indicative).First video test from two different networks
Open
https://meet.yourdomain.comfrom your usual machine and a second device on a different network (a phone on mobile data, for example). If both participants see and hear each other, TURN is working. A test between two machines on the same local network does not validate coturn — it succeeds peer-to-peer without ever using the relay.
Advanced configuration: recording, authentication and limits
Meeting recording. Jitsi includes Jibri for local recording or RTMP streaming. Jibri captures a browser render in real time: one instance records only one meeting at a time, and the Jitsi documentation asks for at least 8 GB of RAM per instance at 720p, preferably on a separate server. Enable ENABLE_RECORDING=1 in .env and start the bundled Jibri container: docker compose -f docker-compose.yml -f jibri.yml up -d. Organizer authentication. By default, anyone can create a room: a public instance left that way becomes free video calling for strangers, on your bandwidth. Enable ENABLE_AUTH=1, AUTH_TYPE=internal and ENABLE_GUESTS=1 to require an account on the organizer's side while letting guests join freely. Create host accounts with docker compose exec prosody prosodyctl --config /run/prosody/config/prosody.cfg.lua register admin meet.jitsi <password>: meet.jitsi is the internal XMPP domain, not your public name, and the password should be generated (openssl rand -base64 18) rather than reused. Limiting anonymous participants. ENABLE_LOBBY=1 makes the lobby available: once the moderator turns it on, each entry must be approved before accessing the room. Combined with ENABLE_AUTH=1, this gives full control over who accesses what.
Hardening: fail2ban on the TURN service
coturn exposes a service reachable from the internet. Install fail2ban and create a filter on failed TURN authentication attempts: failures show up in the coturn log with a 401 code; check the exact format of your version before writing the filter regex. A fail2ban jail with maxretry=10 and bantime=600 is enough to deter automated scans without affecting your legitimate users. Pin the log with log-file=/var/log/turnserver.log in the coturn configuration (without this option, coturn picks a file itself in /var/log, /var/tmp or /tmp), have fail2ban watch that path, or mount that file from the container.
Troubleshooting: the four most common errors
Missing audio or video stuck on 'connecting' — coturn + UDP 10000. This is the most frequent failure. Check in this order: (1) ufw status — are TCP 443 and UDP 10000 open? (2) the coturn logs (journalctl -u coturn when native, docker logs <coturn-container> otherwise) — does coturn start without certificate errors? (3) From an external machine: curl -v telnet://meet.yourdomain.com:3478 — does coturn respond? (4) In browser logs (F12 → Console): do you see ICE failed or failed to gather candidates? If so, coturn is not reachable from outside. Check that TURN_HOST and TURNS_HOST in .env resolve to the actual IP of the coturn server. Connection refused on TCP 443. If Let's Encrypt could not issue a certificate (port 80 was closed on first start), the web container stops after the message Failed to obtain a certificate from the Let's Encrypt CA and nothing answers on 443 any more. Fix: open port 80, check HTTP_PORT=80 and re-run docker compose up -d; there is no need to delete the configuration, and every new attempt counts against Let's Encrypt's limits. Screen sharing refused by the browser. Screen sharing requires a secure context (valid HTTPS). If you see getUserMedia is not supported, the certificate is invalid or the domain is not on HTTPS. Check PUBLIC_URL — it must start with https:// with a domain whose certificate is valid, not a raw IP. Persistent 'ICE failed' error despite a working coturn. Check the JVB_ADVERTISE_IPS variable (formerly DOCKER_HOST_ADDRESS): it must contain the VPS public IP, not the internal Docker container IP (172.17.x.x). If your VPS sits behind cloud NAT (rare but possible), you need the external NAT IP, not the network interface IP.
Monitoring: tracking bandwidth and participants
The JVB video bridge exposes a real-time statistics API at http://localhost:8080/colibri/stats. This JSON endpoint reports the number of active conferences, participants, inbound and outbound bandwidth in kbit/s, and the average RTT (rtt_aggregate). Port 8080 is only published on 127.0.0.1: keep it that way. For continuous monitoring, JVB already exposes its metrics in Prometheus format at /metrics (same private port); on the Prosody side, enable PROSODY_ENABLE_METRICS=1 in .env. Then connect a Prometheus + Grafana stack: the repository ships prometheus.yml and grafana.yml, which publish ports 9090 and 3000 on all interfaces. Restrict them to 127.0.0.1 or a VPN, because ports published by Docker bypass ufw rules.
Without Prometheus, minimal monitoring is achieved with a cron that queries /colibri/stats and alerts when throughput exceeds a threshold: curl -s http://127.0.0.1:8080/colibri/stats | python3 -c "import json,sys; s=json.load(sys.stdin); print(s.get('bit_rate_download',0), s.get('bit_rate_upload',0))".
To estimate capacity before saturation: a JVB's capacity depends first on the network link and the resolutions being sent; measure it on your own load rather than relying on a generic figure. As the limit approaches, rtt_aggregate climbs and perceived quality drops before the CPU saturates — that is the signal to add another JVB instance.
Your Jitsi is running — manage bandwidth
Jitsi runs in SFU mode: the video bridge (JVB) relays each stream to each participant, so outbound bandwidth scales with the number of participants multiplied by the number of streams. A 720p stream weighs about 2.5 Mbit/s according to the Jitsi documentation: multiply by the number of streams actually sent to each participant to estimate outbound throughput. Set channelLastN: 5 in custom-config.js (the web folder of the configuration) to receive only the last N active videos to cap client-side streams in large meetings, and watch docker stats jvb to anticipate saturation. Above about twenty regular simultaneous participants, deploy several JVB instances on separate VPSes behind a single Jicofo: that is Jitsi's native horizontal scaling architecture. For multi-organization or multi-domain deployments, keep in mind that a docker-jitsi-meet installation has a single PUBLIC_URL: if each organization needs its own accounts and an isolated room namespace, plan one instance per organization, on its own subdomain.