How do I run docker-jitsi-meet behind Nginx, Traefik or Caddy?

Short answer

Terminate TLS on your proxy, set DISABLE_HTTPS=1, ENABLE_LETSENCRYPT=0 and PUBLIC_URL to your real https address, then forward all HTTP traffic to the web container's HTTP port, with WebSocket upgrade enabled for /xmpp-websocket. Media never goes through the proxy: UDP 10000 must reach the server directly and JVB_ADVERTISE_IPS must hold the public IP. Since stable-11146 the web container listens on 8000 and 8443 inside the container instead of 80 and 443, and the /colibri-ws route is gone, so older proxy configs that target the container directly need updating.

Who this is for

You already run a reverse proxy (Nginx, Traefik or Caddy) on a server. It owns ports 80 and 443 and your certificates, and you want docker-jitsi-meet to sit behind it like any other app. Or you upgraded to stable-11146 or newer and your existing proxy setup stopped working.

The outcome: Jitsi served at https://meet.example.com through your proxy, signaling over WebSocket working, and audio and video flowing directly to the videobridge.

How it works

A Jitsi meeting uses two very different kinds of traffic, and only one of them can go through an HTTP reverse proxy.

Web and signaling go through the proxy. The web container serves the app and forwards two signaling routes to Prosody: /xmpp-websocket (the default, a WebSocket) and /http-bind (BOSH, the HTTP polling fallback). The handbook names /xmpp-websocket as the one route that needs WebSocket handling. Everything else is plain HTTP to the web container.

Media never goes through the proxy. Audio and video go from browsers straight to the videobridge on 10000/udp, which the jvb service publishes on the host. The handbook is explicit that JVB_ADVERTISE_IPS must be set whether or not you use a reverse proxy, because the advertised IP receives the media and the proxy never sees it. If it is wrong, calls break when a third person joins.

The browser learns its URLs from PUBLIC_URL. The generated config sets config.websocket to wss://<your domain>/xmpp-websocket and config.bosh to https://<your domain>/http-bind. If PUBLIC_URL is unset it falls back to https://localhost:8443, so every visitor’s browser tries to connect to its own machine.

What changed in stable-11146. The web container now listens on unprivileged ports 8000 (HTTP) and 8443 (HTTPS) inside the container, instead of 80 and 443, so it can run without root. The host side did not change: HTTP_PORT and HTTPS_PORT still default to 8000 and 8443, and compose maps them to the new container ports ('${HTTP_PORT}:8000'). Separately, the colibri WebSocket routes /colibri-ws/ and /colibri-relay-ws/ were removed, because the bridge channel now always uses SCTP data channels.

Before you start

  • docker-jitsi-meet stable-11146 or newer (the latest is stable-11248), with compose files and image tags from the same release. Coming from an older release? Follow the rootless upgrade guide first.
  • A DNS A record for meet.example.com pointing at the server.
  • Your proxy holds a valid certificate for meet.example.com. Caddy and Traefik can get one automatically.
  • Firewall: 443/tcp (and 80/tcp for redirects) to the proxy, 10000/udp to the server.
  • Your server’s public IP, used below as 203.0.113.10.
  • Do not serve Jitsi from a subdirectory such as example.com/meet. The handbook warns it does not work well.

Steps

1. Configure .env

In the docker-jitsi-meet folder, edit .env. The first three lines are the handbook’s reverse proxy settings:

Config
DISABLE_HTTPS=1
ENABLE_HTTP_REDIRECT=0
ENABLE_LETSENCRYPT=0
PUBLIC_URL=https://meet.example.com
JVB_ADVERTISE_IPS=203.0.113.10
HTTP_PORT=8000
HTTPS_PORT=8443

With DISABLE_HTTPS=1 the container does not start its HTTPS server block at all, so only HTTP on 8000 answers. Keep ENABLE_HTTP_REDIRECT=0, otherwise the container answers plain HTTP with a redirect to https and your proxy loops. Do not put a port in PUBLIC_URL. It must be the address users type.

2. Keep the container ports off the internet

Bind the published web ports to localhost so only the proxy can reach them. Edit the web service in docker-compose.yml:

YAML
        ports:
            - '127.0.0.1:${HTTP_PORT}:8000'
            - '127.0.0.1:${HTTPS_PORT}:8443'

The official update procedure unzips the new release over your files, so re-apply this edit after every upgrade. Skip this step if your proxy runs in Docker and reaches the container over a shared network (Traefik below).

3. Restart Jitsi

Terminal
docker compose down
docker compose up -d

4a. Nginx on the host

This server block follows the handbook routes, adds forwarded headers, and raises the read timeout. Nginx closes a proxied WebSocket that sends no data for 60 seconds by default. The 900 second value matches the handbook’s Apache example.

Nginx
server {
    listen 80;
    server_name meet.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name meet.example.com;

    ssl_certificate     /etc/letsencrypt/live/meet.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/meet.example.com/privkey.pem;

    location = /xmpp-websocket {
        proxy_pass http://127.0.0.1:8000$request_uri;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 900s;
        tcp_nodelay on;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
    }
}
Terminal
sudo nginx -t && sudo systemctl reload nginx

4b. Traefik with Docker labels

Traefik supports WebSocket connections with no extra configuration. Create docker-compose.override.yml next to docker-compose.yml. The entrypoint websecure, the resolver letsencrypt and the network proxy are placeholders: use the names from your own Traefik setup.

YAML
services:
    web:
        labels:
            traefik.enable: "true"
            traefik.docker.network: "proxy"
            traefik.http.routers.jitsi.rule: "Host(`meet.example.com`)"
            traefik.http.routers.jitsi.entrypoints: "websecure"
            traefik.http.routers.jitsi.tls.certresolver: "letsencrypt"
            traefik.http.services.jitsi.loadbalancer.server.port: "8000"
        networks:
            meet.jitsi:
            proxy:

networks:
    proxy:
        external: true

Set the port label explicitly. When a container exposes several ports, Traefik picks the lowest one. That happens to be 8000 on new images (it was 80 on old ones), but an explicit label written for an old release says 80 and breaks after the upgrade.

4c. Caddy

Caddy proxies WebSockets automatically and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host by default. It also gets and renews the certificate for the site name on its own.

Text
meet.example.com {
    reverse_proxy 127.0.0.1:8000 {
        stream_close_delay 5m
    }
}

By default Caddy closes open WebSockets when its config reloads. stream_close_delay keeps them open for a while, so a reload does not drop everyone from their meetings. If Caddy runs in Docker on the Jitsi network, use reverse_proxy web:8000 instead.

Terminal
sudo systemctl reload caddy

5. Update a proxy config written for stable-11031 or older

  • Any target that points at the container directly (web:80, web:443, or a Traefik port label of 80) must change to web:8000.
  • Delete any /colibri-ws/ or /colibri-relay-ws/ routes, and the ENABLE_COLIBRI_WEBSOCKET, COLIBRI_WEBSOCKET_PORT and COLIBRI_WEBSOCKET_REGEX variables. They no longer exist.
  • Targets that use the host ports (127.0.0.1:8000) keep working, because the host defaults did not change.

Configuration reference

Setting Where Default What it does
PUBLIC_URL .env https://localhost:8443 if unset Public address; builds the browser’s websocket and BOSH URLs
DISABLE_HTTPS .env 0 1 removes the container’s HTTPS server, for TLS on the proxy
ENABLE_HTTP_REDIRECT .env 0 1 makes container HTTP redirect to https; keep 0 behind a proxy
ENABLE_LETSENCRYPT .env off Built-in certificates; needs host ports 80 and 443, so disable behind a proxy
HTTP_PORT .env 8000 Host port mapped to container port 8000
HTTPS_PORT .env 8443 Host port mapped to container port 8443
JVB_ADVERTISE_IPS .env unset IPs the bridge gives browsers for media; set to the public IP
JVB_PORT .env 10000 UDP media port, published directly on the host
ENABLE_XMPP_WEBSOCKET .env 1 0 makes the client fall back to BOSH on /http-bind
BOSH_RELATIVE .env false Uses a relative /http-bind path instead of the full URL

Common mistakes

  • Joining fails or spins forever. PUBLIC_URL is unset or has a port, so the browser connects to the wrong address. Set it to https://meet.example.com and restart.
  • 502 Bad Gateway after upgrading. The proxy still targets container port 80. Change it to 8000.
  • Redirect loop. ENABLE_HTTP_REDIRECT=1 with TLS on the proxy. Set it to 0.
  • Two people work, the third breaks video. UDP 10000 is blocked or JVB_ADVERTISE_IPS is wrong. The proxy cannot help here. See video fails with 3 people.
  • Meetings drop about a minute in. The proxy closes idle WebSockets. Raise proxy_read_timeout in Nginx.
  • Let’s Encrypt errors in the web logs. ENABLE_LETSENCRYPT=1 was left on while the proxy owns port 80. Set it to 0.

Verify

  1. The browser config uses your domain:
Terminal
curl -s https://meet.example.com/config.js | grep -E "config\.(websocket|bosh) ="

Expected:

Text
config.bosh = 'https://meet.example.com/http-bind';
config.websocket = 'wss://meet.example.com/xmpp-websocket';
  1. The WebSocket upgrade passes through the proxy:
Terminal
curl -s -o /dev/null -w "%{http_code}\n" --http1.1 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG9KaXRzaSE=" \
  -H "Sec-WebSocket-Protocol: xmpp" \
  https://meet.example.com/xmpp-websocket

Expected: 101. A 200, 400 or 502 means the upgrade headers are not reaching Prosody.

  1. In the browser, open the developer tools Network tab, filter by WS, join a meeting, and check that xmpp-websocket shows status 101.

  2. Start a meeting with three people on different networks. If video works for all three, media reaches UDP 10000 directly.

If it still fails

  • Read the web container log with docker compose logs --tail=100 web and look for upstream errors on /xmpp-websocket.
  • Read Prosody with docker compose logs --tail=100 prosody. No new connections means traffic stops at the proxy.
  • Check the container answers locally: curl -sI http://127.0.0.1:8000/ should return 200.
  • If the web container fails with open() "/run/web/config/nginx/nginx.conf" failed, check you are on a stable release and not master, which uses nightly unstable images.
  • For media problems, see video fails with 3 people. The proxy is not involved.

What we have not confirmed yet

We checked everything above against the stable-11248 templates and compose file, the Jitsi handbook, the merged pull requests, and the Nginx, Traefik and Caddy documentation on 2026-10-05. We have not yet run every variant on a real server. These points are still open:

  • The Traefik labels, the Caddy block and the Nginx timeouts have not been tested on a live stable-11248 server.
  • The exact WebSocket curl response from Prosody (101 with the xmpp subprotocol and no room parameter) needs confirming on a real server.
  • The handbook’s “Disabling WebSocket connections” section still sets ENABLE_SCTP=1, but that variable no longer appears in the stable-11248 compose files or templates, since PR #2285 made SCTP always on. The handbook looks out of date there.
  • The right /http-bind read timeout depends on the BOSH wait the client requests. 120 seconds is a safe value, not a measured one.
  • Whether docker-compose.override.yml can remove the default public port mappings, instead of editing docker-compose.yml, was not checked.
  • A reverse proxy in front of a Debian/Ubuntu package install, which ships its own Nginx on 443, is not covered here.

Sources

Need a hand?

If Jitsi still will not load through your proxy, contact our engineers with your proxy config, your .env without secrets, and the output of docker compose logs --tail=100 web prosody. Our support plans include reverse proxy setup and testing.

Frequently asked questions

Do I still need to open UDP 10000 if everything goes through my proxy?

Yes. Media never passes through an HTTP proxy. UDP 10000 must reach the server directly, and JVB_ADVERTISE_IPS must be the public IP.

Can I keep Jitsi's built-in Let's Encrypt behind a proxy?

No. It needs host ports 80 and 443, which your proxy already owns. Set ENABLE_LETSENCRYPT=0 and let the proxy handle certificates.

Can I host Jitsi at example.com/meet?

The Jitsi handbook says subdirectory deployments do not work well. Use a subdomain such as meet.example.com.

Do I need to proxy /colibri-ws any more?

No. It was removed in stable-11146. The bridge channel now always uses SCTP data channels, so only /xmpp-websocket needs WebSocket handling.

Should the proxy talk to Jitsi over HTTP or HTTPS?

HTTP on the same host is normal, with TLS ending at the proxy. That is what DISABLE_HTTPS=1 is for.

Stuck, or would rather not do this by hand?

Deploy it in one click

A private Jitsi server in your own AWS account with SSL, your domain and optional recording, transcription and JWT. Free 15 minute trial.

Start free trial

Talk to a Jitsi engineer

Setup, fixes, branding, recording, scaling. Tell us what is happening and we reply with a plan and a quote.

Get expert help

Related

Recently updated