Build Jitsi video Conference on VM
Overview
In this guide, we will build a production-style self-hosted Jitsi Meet setup for online classes.
The final public meeting URL will be:
https://meet.example.com
The design uses separate virtual machines:
VM 100 — Docker Gateway / Traefik / Portainer
VM 101 — Jitsi Meet only
Traefik handles the secure HTTPS web route, while Jitsi media traffic goes directly to the Jitsi VM using UDP 10000. TURN/STUN is also configured for mobile networks and restrictive connections.
This separation is important because Jitsi real-time media traffic should not be treated like a normal web app. Traefik handles HTTP/HTTPS, but Jitsi media needs direct UDP routing.
Official Jitsi Docker documentation also states that for a real deployment, PUBLIC_URL must be set, and that HTTP on port 8000 is useful behind a reverse proxy. (jitsi.github.io)

Final Architecture
Physical Server
Dedicated server
Proxmox VE installed
ZFS RAID1 mirror
Public network bridge: vmbr0
Private VM bridge: vmbr1
Private Network
vmbr1 gateway: 10.10.100.1/24
VM 100 Docker/Traefik: 10.10.100.100
VM 101 Jitsi Meet: 10.10.100.101
Public Traffic Flow
Web traffic
Browser
→ https://meet.example.com
→ Cloudflare DNS-only A record
→ Server public IP
→ Hetzner/firewall TCP 443
→ Proxmox DNAT TCP 443
→ VM 100 Traefik
→ VM 101 Jitsi web backend on http://10.10.100.101:8000
Jitsi media traffic
Browser / app
→ UDP 10000
→ Server public IP
→ Proxmox DNAT
→ VM 101 Jitsi Videobridge
TURN/STUN traffic
Client
→ UDP 3478
→ Server public IP
→ Proxmox DNAT
→ VM 101 coturn
Ports Used
Public ports
TCP 80 → VM 100 Traefik
TCP 443 → VM 100 Traefik
UDP 10000 → VM 101 Jitsi Videobridge
UDP 3478 → VM 101 coturn TURN/STUN
Optional future ports:
TCP 5349 → TURN TLS fallback
UDP 49160-49220 → TURN relay range, if configured
Do not forward public TCP 80 or 443 directly to the Jitsi VM in this design. Traefik already owns public web traffic.
DNS Records
Create DNS-only records, not proxied/orange-clouded:
A meet.example.com → [SERVER_PUBLIC_IPV4]
A turn.example.com → [SERVER_PUBLIC_IPV4]
For Cloudflare, keep them as:
Proxy status: DNS only
This matters because WebRTC media and TURN traffic should not be proxied through Cloudflare.
VM 101 Specification
Recommended Jitsi VM:
VM ID: 101
Name: jitsi-meet
OS: Debian 12
CPU: 6–8 vCPU
RAM: 16 GB
Disk: 120–200 GB
Network: vmbr1
IP: 10.10.100.101/24
Gateway: 10.10.100.1
DNS: 1.1.1.1, 8.8.8.8
Official Jitsi requirements are lower for small setups, but we intentionally use more resources for reliability in live classes. Jitsi’s requirements page also warns that extra services such as recording increase resource needs. (jitsi.github.io)
Step 1 — Prepare VM 101
Install a clean Debian 12 VM.
After installation, configure:
IP address: 10.10.100.101/24
Gateway: 10.10.100.1
DNS: 1.1.1.1 and 8.8.8.8
Install basic tools:
sudo apt update
sudo apt install -y sudo openssh-server curl ca-certificates gnupg qemu-guest-agent nano htop
Enable and start the guest agent:
sudo systemctl enable --now qemu-guest-agent
Verify networking:
ping -c 3 10.10.100.1
ping -c 3 1.1.1.1
ping -c 3 deb.debian.org
sudo apt update
Success means the VM can reach the private gateway, the internet, DNS, and Debian repositories.
Step 2 — Install Docker on VM 101
Install Docker Engine and Docker Compose plugin:
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add the admin user to the Docker group:
sudo usermod -aG docker $USER
Log out and back in, then test:
docker run hello-world
docker compose version
Step 3 — Download Official Jitsi Docker Release
Do not clone the development GitHub repo for production release use.
Use the official Docker release package:
sudo mkdir -p /opt/jitsi
sudo chown -R "$USER:$USER" /opt/jitsi
cd /opt/jitsi
wget "$(wget -q -O - https://api.github.com/repos/jitsi/docker-jitsi-meet/releases/latest | grep zip | cut -d\" -f4)"
unzip stable-*.zip
cd docker-jitsi-meet
Official Jitsi Docker documentation says to download the latest release, copy env.example to .env, run gen-passwords.sh, create the config directories, and then run Docker Compose. (jitsi.github.io)
Copy the environment file:
cp env.example .env
Generate strong passwords:
./gen-passwords.sh
Create config folders:
mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri}
Step 4 — Configure Jitsi .env for Traefik Reverse Proxy
Edit .env:
nano .env
Use these core settings:
PUBLIC_URL=https://meet.example.com
ENABLE_LETSENCRYPT=0
DISABLE_HTTPS=1
ENABLE_HTTP_REDIRECT=0
HTTP_PORT=8000
HTTPS_PORT=8443
JVB_PORT=10000
JVB_ADVERTISE_IPS=[SERVER_PUBLIC_IPV4]
ENABLE_P2P=false
RESOLUTION=360
RESOLUTION_MIN=180
RESOLUTION_WIDTH=640
RESOLUTION_WIDTH_MIN=320
Explanation:
PUBLIC_URL tells Jitsi its real public URL.
DISABLE_HTTPS=1 lets Traefik handle HTTPS.
ENABLE_LETSENCRYPT=0 disables Jitsi’s own cert handling.
HTTP_PORT=8000 gives Traefik a private HTTP backend.
JVB_ADVERTISE_IPS tells clients the real public IP for media.
ENABLE_P2P=false keeps all media through JVB, which is better for future recording bots.
Jitsi’s Docker guide specifically notes that HTTP port 8000 is available for reverse proxy setups and that PUBLIC_URL must be set for real deployments. (jitsi.github.io)
Step 5 — Start Jitsi
From:
cd /opt/jitsi/docker-jitsi-meet
Start the containers:
docker compose pull
docker compose up -d
Check containers:
docker compose ps
Expected containers:
web
prosody
jicofo
jvb
Expected ports:
web: TCP 8000 and 8443
jvb: UDP 10000
Test locally on VM 101:
curl -I http://127.0.0.1:8000
curl -I http://10.10.100.101:8000
Expected result:
HTTP/1.1 200 OK
Step 6 — Add Traefik Route on VM 100
On the Docker/Traefik VM, edit:
/opt/docker/traefik/data/config.yml
Add a Jitsi router and service:
http:
routers:
jitsi-meet:
entryPoints:
- "https"
rule: "Host(`meet.example.com`)"
tls:
certResolver: cloudflare
service: "jitsi-meet"
services:
jitsi-meet:
loadBalancer:
servers:
- url: "http://10.10.100.101:8000"
passHostHeader: true
Restart or reload Traefik:
cd /opt/docker/traefik
docker compose restart traefik
Test from VM 100:
curl -k -I --resolve meet.example.com:443:127.0.0.1 https://meet.example.com/
Expected:
HTTP/2 200
Step 7 — Configure Proxmox DNAT for Jitsi Media
Traefik handles only web traffic. Jitsi media must go directly to VM 101.
On the Proxmox host, update your NAT script to include:
# Jitsi Videobridge media
iptables -t nat -C PREROUTING -i "$WAN_IF" -p udp --dport 10000 -j DNAT --to-destination "$JITSI_VM_IP:10000" 2>/dev/null || \
iptables -t nat -A PREROUTING -i "$WAN_IF" -p udp --dport 10000 -j DNAT --to-destination "$JITSI_VM_IP:10000"
iptables -C FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 10000 -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 10000 -j ACCEPT
Example variables:
WAN_IF="vmbr0"
LAN_IF="vmbr1"
JITSI_VM_IP="10.10.100.101"
Restart NAT service:
systemctl restart vmbr1-nat.service
systemctl status vmbr1-nat.service --no-pager -l
Check rules:
iptables -t nat -L PREROUTING -v -n --line-numbers | grep 10000
iptables -L FORWARD -v -n --line-numbers | grep 10000
Step 8 — Open Firewall Ports
At the provider firewall, allow:
TCP 80
TCP 443
UDP 10000
UDP 3478
Optional later:
TCP 5349
UDP TURN relay range
For this setup, the critical Jitsi media port is:
UDP 10000
If the page loads but audio/video fails, check UDP 10000 first.
Step 9 — Test Jitsi Web and Media
Open:
https://meet.example.com/test-room-1
Join from two devices.
In Jitsi connection stats, verify:
Connection: Good or Acceptable
Transport: udp
Remote port: 10000
Packet loss: 0% or low
If Jitsi web loads but video/audio drops, check:
iptables -t nat -L PREROUTING -v -n --line-numbers | grep 10000
iptables -L FORWARD -v -n --line-numbers | grep 10000
Counters should increase during a meeting.
Step 10 — Install coturn on VM 101
Install coturn:
sudo apt update
sudo apt install -y coturn openssl
Enable coturn:
sudo sed -i 's/^#TURNSERVER_ENABLED=.*/TURNSERVER_ENABLED=1/' /etc/default/coturn
Generate a TURN secret:
openssl rand -hex 32 > /home/jitsiadmin/.turn_secret
chmod 600 /home/jitsiadmin/.turn_secret
Edit coturn config:
sudo nano /etc/turnserver.conf
Example:
listening-port=3478
fingerprint
use-auth-secret
static-auth-secret=[TURN_SECRET]
realm=turn.example.com
server-name=turn.example.com
listening-ip=10.10.100.101
relay-ip=10.10.100.101
external-ip=[SERVER_PUBLIC_IPV4]/10.10.100.101
min-port=49160
max-port=49220
no-tls
no-dtls
no-cli
Start coturn:
sudo systemctl enable --now coturn
sudo systemctl status coturn --no-pager -l
Verify listening ports:
sudo ss -lntup | grep 3478
Expected:
UDP 3478
TCP 3478
Step 11 — Add TURN DNAT on Proxmox
Add NAT forwarding for TURN:
# TURN/STUN UDP 3478
iptables -t nat -C PREROUTING -i "$WAN_IF" -p udp --dport 3478 -j DNAT --to-destination "$JITSI_VM_IP:3478" 2>/dev/null || \
iptables -t nat -A PREROUTING -i "$WAN_IF" -p udp --dport 3478 -j DNAT --to-destination "$JITSI_VM_IP:3478"
iptables -C FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 3478 -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 3478 -j ACCEPT
Optional relay range:
iptables -t nat -C PREROUTING -i "$WAN_IF" -p udp --dport 49160:49220 -j DNAT --to-destination "$JITSI_VM_IP" 2>/dev/null || \
iptables -t nat -A PREROUTING -i "$WAN_IF" -p udp --dport 49160:49220 -j DNAT --to-destination "$JITSI_VM_IP"
iptables -C FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 49160:49220 -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -i "$WAN_IF" -o "$LAN_IF" -p udp -d "$JITSI_VM_IP" --dport 49160:49220 -j ACCEPT
Restart NAT service:
systemctl restart vmbr1-nat.service
Step 12 — Advertise TURN in Jitsi
On VM 101:
cd /opt/jitsi/docker-jitsi-meet
Add TURN settings to .env:
TURN_HOST=turn.example.com
TURN_PORT=3478
TURN_TRANSPORT=udp
TURN_CREDENTIALS=[TURN_SECRET]
Keep:
ENABLE_P2P=false
Recreate Jitsi containers:
docker compose up -d --force-recreate
Verify Prosody generated TURN config:
docker compose exec -T prosody sh -lc '
grep -nEi "external_services|turn.example.com|3478|external_service_secret" /config/prosody.cfg.lua 2>/dev/null \
| sed -E "s/(external_service_secret = ).*/\1[HIDDEN];/"
'
Expected:
external_services
turn.example.com
port = 3478
external_service_secret = [HIDDEN]
Step 13 — Test on Wi-Fi and Mobile Data
Test:
Desktop/laptop on Wi-Fi
Phone on 5G/mobile data
Open:
https://meet.example.com/test-turn-5g-1
Watch counters on Proxmox:
iptables -t nat -L PREROUTING -v -n --line-numbers | grep -E '10000|3478|49160|49220|10.10.100.101'
iptables -L FORWARD -v -n --line-numbers | grep -E '10000|3478|49160|49220|10.10.100.101'
A good result:
Connection: Good
Packet loss: 0%
Audio works
Video works
No disconnect
In our test, the phone on low 5G worked well in the mobile browser with audio/video and no disconnect.
Step 14 — Recommended Quality Settings
For low-internet online classes:
ENABLE_P2P=false
RESOLUTION=360
RESOLUTION_MIN=180
RESOLUTION_WIDTH=640
RESOLUTION_WIDTH_MIN=320
This gives:
Preferred: 360p
Minimum: 180p
Audio priority
Server-routed media through JVB
Recommended classroom policy:
Default/group/recorded classes:
360p default, 480p max later, audio priority, P2P off
Pure 1-to-1 non-recorded classes:
Optional 720p profile later, only through a classroom wrapper app
Recorder-bot or 3+ participant classes:
No 720p, use JVB routing, P2P off
Step 15 — Branding Cleanup
For a quick branding improvement, hide the Jitsi watermark and add text branding such as:
Ulamaify Meet
The simple temporary approach is CSS injection into Jitsi’s web assets. This is not the final permanent branding method, but it works for a clean launch.
Final branding should later happen in:
classroom.example.com
That wrapper page can show your logo, class name, recording notice, and then embed or launch the Jitsi room.
Step 16 — Recording Decision
Do not install Jibri on the Jitsi VM.
Official Jitsi requirements say Jibri needs one system per recording, uses much higher CPU/RAM/disk, and is not recommended on the same server as Jitsi Meet because it can harm performance and exhaust disk. (jitsi.github.io)
Temporary recording options:
Jitsi local recording:
Works, saves .webm locally, but files can be large and it is hard for teachers.
OBS:
Works, but teacher workflow is still too complicated.
Best temporary decision:
Do not require teachers to record manually.
Official Jitsi FAQ confirms local recording saves a WebM file on the device. (jitsi.github.io)
Final production recording plan:
Separate Recorder Bot VM
Bot joins as third participant
Records audio
Takes snapshots every 3–5 minutes
Uploads files
n8n handles transcription/report generation later
Do not put recording bots, Jibri, n8n, transcription, databases, or AI workloads on the Jitsi VM.
Step 17 — Mobile App Policy
In testing:
Jitsi app on Wi-Fi: better
Jitsi app on 5G/mobile data: poor audio/video
Mobile browser on 5G: better
Recommended student policy:
Desktop/laptop:
Use Chrome or Edge browser.
Phone/tablet on Wi-Fi:
Use app or browser.
Phone/tablet on mobile data/5G:
Use browser first.
Later app troubleshooting can test:
Jitsi app on 5G while watching UDP 10000 / UDP 3478 counters
App connection stats screenshots
TURN TCP/TLS 5349
Possibly TURN-over-443, but only with careful design because Traefik already uses TCP 443
Step 18 — Snapshots
After the setup works, create snapshots.
VM 101:
qm snapshot 101 jitsi-turn-5g-working \
--description "Jitsi working with P2P disabled, 360p profile, UDP 10000 media, coturn UDP 3478 advertised, phone 5G browser test successful"
VM 100:
qm snapshot 100 docker-traefik-jitsi-route-clean \
--description "Traefik clean Jitsi route: meet.example.com only, old domains removed, route returns HTTP/2 200"
Snapshots are good rollback points, but they are not a replacement for off-server backups.
Final Working Checklist
A working deployment should have:
meet.example.com opens publicly
Traefik route returns HTTP/2 200
Old temporary domains return 404
Jitsi containers are Up
Local Jitsi web returns HTTP/1.1 200 OK
UDP 10000 reaches VM 101
coturn is active
UDP 3478 reaches VM 101
Phone browser on 5G works with no disconnect
P2P is disabled
360p profile is active
Snapshots are created
Useful checks:
docker compose ps
curl -I http://127.0.0.1:8000
sudo ss -lntup | grep -E ':8000|:8443|:10000|:3478'
iptables -t nat -L PREROUTING -v -n --line-numbers | grep -E '10000|3478'
iptables -L FORWARD -v -n --line-numbers | grep -E '10000|3478'
curl -k -I https://meet.example.com/
Final Summary
This setup gives you a clean, separated Jitsi Meet deployment:
VM 100 handles HTTPS and routing with Traefik.
VM 101 runs only Jitsi and TURN.
UDP 10000 is forwarded directly for media.
UDP 3478 is used for TURN/STUN.
Video is conservative at 360p for low-internet users.
P2P is disabled to prepare for future recorder-bot classes.
Recording is intentionally postponed until a dedicated server-side Recorder Bot VM is built.

