Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Jitsi Meet video conferencing on a VPS with the official Docker setup, Caddy handling HTTPS, the videobridge's UDP media port opened and advertised correctly, and an upgrade path that does not break running meetings.
Jitsi Meet is open-source video conferencing that runs in the browser: people open a link, type a name and are in a room, with no account and no app to install. It supports screen sharing, chat, raising a hand, and recording through an optional add-on. It is licensed Apache-2.0, written mostly in TypeScript (the web client) and Java (the server components), and rated 3 / 5 to deploy, because unlike most apps in this catalog it is not just a web server: the real work happens over UDP.
The Docker release is four cooperating pieces. A web container serves the
page, Prosody is the XMPP server that holds rooms and presence, Jicofo
coordinates each conference, and the JVB (Jitsi Videobridge) forwards the
actual audio and video streams between participants. This guide runs all four
with the official docker-jitsi-meet release, puts Caddy in front for HTTPS,
and spends extra time on the one step most failed installs get wrong:
making media reachable. For picking a provider and tier, see
Best VPS for self-hosting.
The catalog lists 2 GB as the minimum for this stack. The idle numbers look much kinder than that: on the catalog's test box the four containers together used roughly 336 MB of RAM with nobody in a room, and the install took about 1.7 GB of disk (the Java images are large). In our own verification run the videobridge sat near 175 MB, Jicofo near 145 MB, Prosody near 20 MB, the web container near 14 MB and Caddy near 14 MB, which is about 370 MB for the whole stack, still with nobody in a room.
Do not size from the idle figure. The videobridge is a forwarding unit, so its cost grows with the number of participants and the number of video streams it has to relay, and CPU and network bandwidth run out before memory does. A small team on a 2 vCPU server is comfortable; a classroom of dozens of cameras is a bandwidth problem, not an install problem, and means a larger plan or more bridges. Choose a provider with generous or unmetered outbound traffic, since a conference sends out a copy of every stream to every participant.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you're logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Open SSH, the two web ports, and the videobridge's media port. The last one is UDP, and it is the one people forget:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 10000/udp
sudo ufw --force enable
If your provider has a network-level firewall in front of the server (a
"security group", cloud firewall or VPC rule), open the same ports there too.
A rule in ufw alone is not enough when the provider blocks UDP upstream.
The web container will be reachable only through Caddy. A published container
port goes around ufw on a Docker host, so we will bind the web ports to
127.0.0.1 below, and only Caddy and the UDP media port face the internet.
Paid link — we earn a commission if you shop through it.
If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version
The group change applies on your next login; until then the commands below use
sudo docker.
Download the official docker-jitsi-meet release, pinned to a tag rather than
a moving branch, and unpack it into ~/jitsi. The retries are there because
GitHub downloads occasionally fail:
mkdir -p ~/jitsi && cd ~/jitsi
curl -fsSL --retry 5 --retry-delay 3 https://github.com/jitsi/docker-jitsi-meet/archive/refs/tags/stable-11248.tar.gz | tar xz --strip-components=1
cp env.example .env
./gen-passwords.sh
grep -c -E '^(JICOFO|JVB)_AUTH_PASSWORD=.+' .env
gen-passwords.sh fills the internal XMPP passwords in .env with random
values. They are only used between the containers, but the services refuse to
start without them. The grep should print 2.
Jitsi keeps its configuration, and a few runtime directories, under
~/.jitsi-meet-cfg. Create them before the first start. The containers run
read-only with unprivileged users, so the storage directories have to be
writable:
mkdir -p ~/.jitsi-meet-cfg/{web,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri,transcriber}
mkdir -p ~/.jitsi-meet-cfg/storage/{jibri,prosody,transcripts,web} ~/.jitsi-meet-cfg/tmp/{web-crontabs,web-load-test}
chmod 777 ~/.jitsi-meet-cfg/storage/{jibri,prosody,transcripts,web} ~/.jitsi-meet-cfg/tmp/{web-crontabs,web-load-test}
The chmod 777 is the instruction in upstream's own Docker handbook. It leaves
those directories world-writable under your home directory, so tighten the
permissions afterwards if other users share the machine.
Now configure it for a reverse proxy. Set your hostname first (this guide uses
meet.example.com; replace it with yours), and let the script read the
server's public IPv4 address, which the videobridge must advertise:
cd ~/jitsi
DOMAIN=meet.example.com
PUBLIC_IP=$(curl -4 -fsS --retry 3 https://ifconfig.me)
echo "public address: $PUBLIC_IP"
sed -i -e 's|^HTTP_PORT=.*|HTTP_PORT=127.0.0.1:8000|' -e 's|^HTTPS_PORT=.*|HTTPS_PORT=127.0.0.1:8443|' \
-e "s|^CONFIG=.*|CONFIG=$HOME/.jitsi-meet-cfg|" .env
cat >> .env <<EOF
# --- set for the Caddy reverse proxy ---
PUBLIC_URL=https://$DOMAIN
DISABLE_HTTPS=1
ENABLE_HTTP_REDIRECT=0
ENABLE_LETSENCRYPT=0
JVB_ADVERTISE_IPS=$PUBLIC_IP
DOMAIN=$DOMAIN
EOF
grep -E '^(CONFIG|HTTP_PORT|HTTPS_PORT|PUBLIC_URL|DISABLE_HTTPS|JVB_ADVERTISE_IPS)=' .env
The CONFIG line is the gotcha we hit. The stock env.example sets
CONFIG=~/.jitsi-meet-cfg, and that ~ is expanded by Docker Compose at the
moment it runs. Run it with sudo, as this guide does until your group change
applies, and ~ becomes /root, so Compose bind-mounts an empty root-owned
/root/.jitsi-meet-cfg instead of the /home/you/.jitsi-meet-cfg you just
prepared. Everything looks fine until Prosody, the XMPP server, loops in
Restarting with directory '/var/lib/prosody' is not writable by the container user, the page still loads (the web container is fine), and every join attempt
fails with a 502. Writing the absolute path into .env, as the command above
does, removes the problem for both sudo and non-sudo runs.
What each setting does: PUBLIC_URL is the address people browse to, used to
build links and the websocket address. DISABLE_HTTPS=1 tells the web
container that TLS is handled elsewhere, so it serves plain HTTP on port 8000.
JVB_ADVERTISE_IPS is the address the videobridge hands to every browser as
"send your media here". On a cloud VM the server only knows its private
address, so without this some clients are told an address they cannot reach.
Jitsi's handbook calls this setting necessary regardless of any reverse proxy,
because a proxy handles web traffic and never touches media.
Add Caddy in front. Compose automatically loads a file named
docker-compose.override.yml next to docker-compose.yml, so the official
file stays untouched and upgrades do not overwrite your changes. Caddy has to
join Jitsi's own meet.jitsi network to reach the web container by name:
cd ~/jitsi
cat > docker-compose.override.yml <<'EOF'
services:
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443"]
environment:
DOMAIN: ${DOMAIN}
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
depends_on: [web]
networks:
- meet.jitsi
volumes:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy web:8000
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
Caddy proxies WebSocket connections without extra configuration, which matters
because Jitsi signalling runs over /xmpp-websocket. Start the stack. The
first start pulls several large images, so give it a few minutes:
cd ~/jitsi
sudo docker compose up -d
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
The services do not become ready at the same moment, so wait for the videobridge to report healthy instead of guessing. The loop is bounded so a broken install fails instead of hanging:
health=
for i in $(seq 1 40); do
health=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/about/health)
[ "$health" = "200" ] && break
sleep 3
done
echo "videobridge health: HTTP $health"
Create an A record for your hostname (say meet.example.com) pointing at
the server's public IP, and confirm it resolves before Caddy tries to get a
certificate:
dig +short meet.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:
. ~/jitsi/.env
out=000
for i in $(seq 1 20); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/") && [ "${out%% *}" = "200" ] && break
sleep 5
done
echo "HTTPS status and certificate check: $out"
[ "${out%% *}" = "200" ] || false
curl -s "https://$DOMAIN/" | grep -o '<title>[^<]*</title>'
A second number of 0 means the certificate verified. Next, check the part
that is easy to get wrong behind a proxy, the WebSocket the browser uses to
join a room. A correct reply is HTTP/1.1 101, which means the connection
switched protocols (a 502 here means Prosody is down; see Troubleshooting):
. ~/jitsi/.env
status=$(curl -s -i --http1.1 --max-time 5 -o - \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Sec-WebSocket-Protocol: xmpp' "https://$DOMAIN/xmpp-websocket?room=testroom" | head -1)
echo "websocket upgrade: $status"
echo "$status" | grep -q ' 101 '
The page loading proves HTTPS and signalling. It does not prove that audio and video will flow, and a Jitsi install can look perfect and still show a black tile for every other participant. First confirm what the videobridge is advertising and listening on:
cd ~/jitsi
sudo docker compose logs jvb 2>&1 | grep -iE "advertis|harvester|ice4j.*(public|mapping)|10000" | head -8
sudo ss -ulpn | grep ':10000' || echo "nothing is bound to UDP 10000"
The log lines should name your public address, and UDP 10000 should be bound.
Then do the test no script can do for you: open https://meet.example.com in
two different browsers, ideally one on another network such as a phone off
Wi-Fi, join the same room, and confirm each side sees and hears the other. We
confirmed the installation, signalling, certificate and videobridge health in
our own run, but a two-device call is yours to make, because it depends on your
provider's network path. If you see yourself but nobody else, jump to
Troubleshooting.
A fresh install lets anyone who knows the address create rooms. For a public
server that is a recipe for strangers using your bandwidth, so decide on
purpose. The simplest option is to require a login to create a room while
still letting guests join with the link. Add these to ~/jitsi/.env:
cd ~/jitsi
cat >> .env <<'EOF'
ENABLE_AUTH=1
ENABLE_GUESTS=1
AUTH_TYPE=internal
EOF
sudo docker compose up -d
sudo docker compose exec prosody prosodyctl --config /run/prosody/config/prosody.cfg.lua register alice meet.jitsi 'a-long-random-password'
The register command needs the full path to the generated config inside the
container, /run/prosody/config/prosody.cfg.lua; the /config directory
that the handbook's examples use was empty in our run, and pointing at it
fails with "unable to find the configuration file". It also only works once
ENABLE_AUTH=1 is in effect, because only then does Prosody serve the
meet.jitsi domain for accounts. We ran it and Prosody reported User account created; we did not test logging in through the browser.
With that in place a moderator logs in to open a room, and others join as
guests once it is open. Keep the passwords in a password manager, and remember
that the internal XMPP domain, meet.jitsi, is Jitsi's default and is not
your public hostname.
A Jitsi server has almost no data that matters. Rooms are created on demand and
vanish when they empty, and nothing is recorded unless you add Jibri. What you
want to keep is the configuration: .env, which holds the internal
passwords, the override file, the Caddyfile, and the ~/.jitsi-meet-cfg
directory, which holds generated config and, if you enabled authentication, the
Prosody accounts. These archives contain secrets, so they are created readable
by their owner only:
cd ~/jitsi
( umask 077
sudo tar czf ~/jitsi-config-$(date +%F).tar.gz .env docker-compose.override.yml Caddyfile -C ~ .jitsi-meet-cfg )
sudo chown "$USER" ~/jitsi-config-*.tar.gz
ls -lh ~/jitsi-config-*.tar.gz
Copy the file off the server. To restore, install Docker, unpack the
archive, download the same release tag into ~/jitsi without overwriting
.env, and run sudo docker compose up -d. If you add the optional Jibri
recorder later, recordings are real data and need their own backup.
Jitsi publishes numbered stable-NNNN releases, and the Docker repository
matches. An upgrade means a newer release tag of the same repository, and
the images are tagged to match, so never mix versions. Read the release notes
for anything that changes env.example, take a backup first, then:
cd ~/jitsi
cp .env ~/jitsi-env-before-upgrade
NEW=stable-NNNN # the new tag from github.com/jitsi/docker-jitsi-meet/releases
curl -fsSL "https://github.com/jitsi/docker-jitsi-meet/archive/refs/tags/$NEW.tar.gz" | tar xz --strip-components=1 --exclude=.env --exclude=docker-compose.override.yml --exclude=Caddyfile
diff <(grep -oE '^#?[A-Z_]+=' env.example | sort -u) <(grep -oE '^#?[A-Z_]+=' .env | sort -u) | head
sudo docker compose pull
sudo docker compose up -d
The diff shows variables the new env.example has that your .env lacks;
copy any you need. Upgrading drops every live call, so do it between
meetings. Participants reconnect on their own when the stack comes back.
The page loads but nobody sees or hears anyone else. This is the classic
failed Jitsi install and it is nearly always media, not the web. Check that
ufw and your provider's firewall allow 10000/udp, that
JVB_ADVERTISE_IPS in .env is your real public IPv4 address, and that
sudo ss -ulpn | grep 10000 shows the port bound. A network that blocks UDP
entirely, common on corporate Wi-Fi, will not work until you add a TURN server
or the TCP fallback; Jitsi's handbook covers both.
"Allow camera and microphone" never appears, or the browser says the page is
not secure. The page was opened over plain HTTP or a bare IP. Browsers only
grant camera and microphone access on HTTPS or on localhost, so use the
Caddy address.
HTTPS works but joining a room hangs on "connecting". The WebSocket is
being dropped. Re-run the 101 check above; if it fails, the proxy is not
forwarding /xmpp-websocket, or PUBLIC_URL does not match the address you
browse to.
Caddy never gets a certificate. Confirm dig +short meet.example.com
returns the server's IP and that your provider's firewall allows inbound 80 and
443. A blocked port 80 silently breaks the ACME challenge.
A container restarts in a loop with a permissions error. Two causes, in
order of likelihood. First, CONFIG in .env still says ~/.jitsi-meet-cfg
and you ran Compose with sudo, so it is mounting /root/.jitsi-meet-cfg;
check with sudo docker inspect jitsi-prosody-1 --format '{{json .HostConfig.Binds}}'
and set CONFIG to the absolute path. Second, the directories under
~/.jitsi-meet-cfg/storage and ~/.jitsi-meet-cfg/tmp are not writable by the
container users; this is why the install step runs chmod 777 on them.
You're done when https://meet.example.com loads over a valid certificate, the
WebSocket check returns 101, videobridge health reports 200, two devices on
different networks can see and hear each other, and sudo docker compose ps
shows every service up after a reboot (the restart policies bring them back).
For the measurement behind the sizing section, run
sudo docker stats --no-stream while a call is in progress and watch the
jvb container. From here, set up authentication if you have not, look at
Jibri for recordings, and read the catalog entry at
Jitsi Meet. The ranked server picks are in
Best VPS for self-hosting.