Server documentation · English
Squorli behind an existing reverse proxy.
Squorli Server ships its own HTTPS proxy, Caddy. If nginx, Nginx Proxy Manager, Plesk, Traefik or a Caddy of your own already runs on your host, run Squorli in PROXY_MODE=external behind that proxy instead. This page explains what the proxy has to do, how it reaches the containers and which settings each proxy needs. The configuration files and overlays live in the server repository under deploy/proxies.
The simplest way is to choose “A reverse proxy on this machine” or “on another machine” in the installer: it sets PROXY_MODE=external, picks the matching overlay and names the targets for the proxy at the end; the files then live in /opt/squorli/deploy. With the manual installation, set PROXY_MODE=external in .env and use the Compose profile external.
What the proxy must do
- Forward
https://chat.example.org/rtc*, including WebSocket, to LiveKit (livekit:7880). - Forward everything else under
https://chat.example.org, including the WebSocket/api/ws, to the app server (server:3000). - Set the
X-Forwarded-ForandX-Forwarded-Protoheaders and pass the WebSocket upgrade through.
In addition, 7882/udp and 7881/tcp must be reachable directly on the host, not through the proxy: audio and video use them. Once TURN is active (see deploy/livekit/livekit.yaml), 5349/tcp is added.
Only subdomains of your own such as chat.example.org are supported. A sub-path such as example.org/chat does not work.
How the proxy reaches the containers
In external mode compose.yml publishes no HTTP ports; server and livekit are only attached to the Docker network squorli_internal. Until 28 September 2026 the app server's service was named app; a proxy in a container that still points to app:3000 keeps working, because the container answers to both names. Depending on where your proxy runs, you need one of the overlays:
| The proxy runs … | Solution |
|---|---|
| on the host (nginx, Apache, Caddy as a package) | Overlay nginx.ports.yml: publishes 127.0.0.1:3000 and 127.0.0.1:7880; from outside both stay closed. |
| as a container on the same host (Traefik, Nginx Proxy Manager, Caddy) | Attach the proxy container to the network squorli_internal, or server/livekit to the proxy's network (npm.network.yml, traefik.labels.yml), and use the container names server and livekit as targets. |
| on another host | Overlay remote-proxy.ports.yml: publishes 3000 and 7880 on PROXY_BIND_IP; the target in the proxy is the IP or internal hostname of the chat host. See Proxy on another host. |
Proxy on the host, for example nginx from the package manager:
# Run from squorli/deploy: proxy installed on this host
docker compose --env-file ../.env -f compose.yml -f proxies/nginx.ports.yml --profile external pull
docker compose --env-file ../.env -f compose.yml -f proxies/nginx.ports.yml --profile external up -d --no-buildProxy on another host
Applies to Nginx Proxy Manager, nginx, Traefik and others on a second machine. What changes:
- Open the ports: in
.envsetPROXY_BIND_IPto the LAN or VPN address of the chat host (if unset: all interfaces) and start with the overlayremote-proxy.ports.yml. Then, in the chat host's firewall, allow3000/tcpand7880/tcponly for the IP of the proxy host. Both ports speak unencrypted HTTP; there should be a private network or VPN between the hosts. - Target in the proxy: instead of
server/livekit, the IP or the internal hostname of the chat host, ports 3000 and 7880. - Media does not go through the proxy host. Browsers connect directly to the chat host for audio:
7882/udpand7881/tcpmust be reachable there from the internet (public IP or port forwarding on the router). LiveKit must know this public address: the default is automatic detection (use_external_ip); with NAT or multiple addresses setLIVEKIT_NODE_IP=<public IP of the chat host>in.env. A chat host without its own public reachability does not work, no matter how the proxy is set up. TRUSTED_PROXIES: the app server sees the proxy host as the sender. If its IP lies in10/8,172.16/12or192.168/16, the default is sufficient; otherwise add the IP in.env. Under Docker Desktop the sender appears as the Docker gateway172.x, also covered.
# Run from squorli/deploy: proxy on another host; PROXY_BIND_IP is set in .env
docker compose --env-file ../.env -f compose.yml -f proxies/remote-proxy.ports.yml --profile external pull
docker compose --env-file ../.env -f compose.yml -f proxies/remote-proxy.ports.yml --profile external up -d --no-buildnginx on the host
.env:PROXY_MODE=external.TRUSTED_PROXIEScan stay at the default (Docker networks and 127.0.0.1); the app server sees nginx as a sender from the Docker bridge network.- Start with the overlay
nginx.ports.yml(commands above). - Adopt the following server block, for example as
/etc/nginx/sites-available/chat.conf, adjustchat.example.organd the certificate paths, then reload nginx:nginx -t && systemctl reload nginx. - Firewall: open
443/tcp,80/tcp(redirect),7881/tcp,7882/udp.
map $http_upgrade $connection_upgrade { default upgrade; "" close; }
# HTTP -> HTTPS
server {
listen 80;
listen [::]:80;
server_name chat.example.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name chat.example.org;
# Certificate as usual, e.g. certbot:
# ssl_certificate /etc/letsencrypt/live/chat.example.org/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/chat.example.org/privkey.pem;
client_max_body_size 30m; # >= MAX_UPLOAD_MB plus the form data around the file
# LiveKit signaling (WebSocket under /rtc, validation under /rtc/validate)
location /rtc {
proxy_pass http://127.0.0.1:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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 3600s;
proxy_send_timeout 3600s;
}
# App server: API, WebSocket /api/ws, static web client
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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 3600s;
proxy_send_timeout 3600s;
}
}The file nginx.conf in the repository contains the same block. If nginx itself runs as a container, attach it to the network squorli_internal and replace 127.0.0.1:3000 with server:3000 and 127.0.0.1:7880 with livekit:7880.
The nginx, Traefik and Caddy examples are checked automatically on every change to the server: a test starts Squorli behind the real proxy and checks through it the health endpoint, the WebSocket, /rtc, the sender address, a full-size upload and the setup check. Nginx Proxy Manager and Plesk are not part of it. Check your installation after starting with the commands under Check.
Nginx Proxy Manager
Nginx Proxy Manager (NPM) on the same host runs as a container and must reach server and livekit in the Docker network. Start with the overlay npm.network.yml and adjust the network name in it (docker inspect <npm-container> shows it). NPM on another host: follow the section Proxy on another host and, in the table below, enter the IP of the chat host as Forward Hostname instead of server/livekit.
# Run from squorli/deploy: Nginx Proxy Manager as a container on this host
docker compose --env-file ../.env -f compose.yml -f proxies/npm.network.yml --profile external pull
docker compose --env-file ../.env -f compose.yml -f proxies/npm.network.yml --profile external up -d --no-buildAlternatively without an overlay:
docker network connect squorli_internal <npm-container>Create a Proxy Host in the NPM interface:
| Tab | Field | Value |
|---|---|---|
| Details | Domain Names | chat.example.org |
| Details | Scheme / Forward Hostname / Port | http / server / 3000 |
| Details | Websockets Support | on (required, otherwise no /api/ws) |
| Details | Cache Assets | off |
| Details | Block Common Exploits | optional |
| Custom Locations | location /rtc | Scheme http, Forward Hostname livekit, Port 7880 |
| Custom Locations | gear icon at /rtc (Advanced) | the two timeout lines below |
| SSL | Certificate | Request Let's Encrypt |
| SSL | Force SSL, HTTP/2 Support | on |
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;Mind the order: /rtc is a Custom Location of the Proxy Host for chat.example.org, not a separate host. In older NPM versions Custom Locations do not get the WebSocket upgrade automatically; if joining fails with a WebSocket error to /rtc, add in the Advanced field of the location:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";Add proxy_http_version 1.1; only if NPM does not already set it there; otherwise nginx reports a duplicate directive and the host goes offline.
Firewall as with nginx: 443/tcp, 80/tcp, 7881/tcp, 7882/udp directly to the host; the media ports do not go through NPM. TRUSTED_PROXIES in .env can stay at the default, since NPM comes from a Docker network.
Plesk
The Plesk host's nginx sits in front of the containers; this works with the Portainer stack (deploy/portainer.yml) or the Docker extension on the same host. Do not use Plesk's "Docker Proxy Rules": the generated locations carry no Upgrade/Connection headers, so /api/ws and /rtc fail.
- Stack:
PROXY_BIND_IP=127.0.0.1(3000 and 7880 only reachable by the host's nginx),TRUSTED_PROXIESat its default (the container sees nginx as the Docker gateway172.x),LIVEKIT_NODE_IP= public IP of the Plesk host. - Plesk › domain › Hosting & DNS › Apache & nginx Settings › "Additional nginx directives". The locations are regex locations so that they do not collide with Plesk's own
location /; order matters, the first match wins:
location ~ ^/rtc {
proxy_pass http://127.0.0.1:7880;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
location ~ ^/api/ws {
proxy_pass http://127.0.0.1:3000;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
location ~ ^/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 30m; # >= MAX_UPLOAD_MB plus the form data around the file
}- Certificate: Let's Encrypt for the domain in Plesk (SSL/TLS Certificates), "Permanent SEO-safe 301 redirect from HTTP to HTTPS" on.
- Plesk Firewall: allow inbound
7881/tcpand7882/udp(media goes directly to LiveKit); 80/443 as usual. 3000 and 7880 stay closed. - Check:
https://<domain>/api/healthreturns JSON withdomain= the Plesk domain,https://<domain>/rtc/validatereturns 401, and in the browser the debug view (?debug) shows the ICE path after joining a voice channel.
Traefik
The overlay traefik.labels.yml adds the router labels for chat.example.org (app server) and /rtc (LiveKit, higher priority). It expects Traefik on a Docker network named proxy with the entrypoint websecure and the certificate resolver letsencrypt; rename them to yours, the network name in the label traefik.docker.network too: app and LiveKit sit in two networks, and without that label Traefik may pick the internal one it cannot reach (502/504 or hanging requests). Traefik handles WebSockets and X-Forwarded-For by itself and has no upload size limit by default.
# Run from squorli/deploy: Traefik as a container on this host
docker compose --env-file ../.env -f compose.yml -f proxies/traefik.labels.yml --profile external pull
docker compose --env-file ../.env -f compose.yml -f proxies/traefik.labels.yml --profile external up -d --no-buildCaddy
For a Caddy you already run for other sites. Without one, prefer PROXY_MODE=bundled: then Squorli brings its own Caddy. Start with the overlay nginx.ports.yml as for nginx on the host, adopt the following block into your Caddyfile, replace chat.example.org and reload Caddy (caddy reload). Caddy handles the certificate, the redirect to HTTPS, WebSockets and X-Forwarded-For by itself.
chat.example.org {
# LiveKit signaling (WebSocket under /rtc, validation under /rtc/validate)
reverse_proxy /rtc* 127.0.0.1:7880
# App server: API, WebSocket /api/ws, static web client
reverse_proxy 127.0.0.1:3000
}The file Caddyfile.external in the repository contains the same block. If Caddy runs as a container, attach it to the network squorli_internal and use server:3000 and livekit:7880 as targets. Firewall and checks as for nginx.
Squorli Directory
The directory service (handles, key backup, authenticator, friends) is a separate service on its own host with its own domain; in the proxy it is an ordinary host without a path prefix. Your chat server only needs DIRECTORY_URL=https://directory.squorli.com in .env. Nothing in the server repository publishes or proxies the service.
Check and troubleshoot
curl -s https://chat.example.org/api/health
curl -s -o /dev/null -w '%{http_code}\n' https://chat.example.org/rtc/validateThe first answer is JSON with "proxyMode":"external"; the second is 401: the request reached LiveKit, which refuses without a token. Then log in in the browser, join the lobby and check in the debug view (?debug) that packets arrive.
Faster: squorli doctor on the server or Verwaltung › Server › “Check the connection” in the client. Both check the domain, the certificate, the WebSocket upgrade, /rtc, the media ports and the trusted proxies and name the likely cause; the check in the client also tells whether media arrive over UDP or over TCP only (description).
- If the WebSocket
/api/wsdoes not open,proxy_set_header UpgradeandConnectionare usually missing. - If
/rtcconnects but no audio comes through,7882/udpand7881/tcpare not open or LiveKit does not know its public IP: setLIVEKIT_NODE_IPin.env. - If
/api/healthshows a differentdomainthan expected,PUBLIC_DOMAINin.envdoes not match the hostname the browsers use.
Updates and backups · Administer your server · Overlays and nginx.conf in the repository