How We Built a Self-Hosted Jitsi Meet Server on Proxmox with Traefik, Cloudflare SSL, UDP Media, and TURN

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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *