Skip to content

Reverse Proxy

Moepchi edited this page Oct 5, 2026 · 2 revisions

Running behind a Reverse Proxy

Browsers only allow microphone access on HTTPS pages (or localhost), so a public WebSpeak3 instance needs TLS. You can let the gateway do that itself (TLS_CERT / TLS_KEY), but usually it sits behind a reverse proxy that handles certificates.

What the proxy has to pass through

Everything is served by the gateway on one port (default 8080):

Path What
/ws The WebSocket the browser talks to. Must be proxied as a WebSocket (Upgrade / Connection headers). This is the most common mistake: the page loads, but connecting never works.
/api/* Optional endpoints (broadcast, Design Store, feedback).
/healthz Returns ok, for health checks.
everything else The web client itself.

The gateway sends a keepalive every 20 seconds, so default proxy timeouts are fine. A single WebSocket message can be up to 34 MiB (file transfers), which none of the proxies below limit by default.

TRUST_PROXY and the client IP

Behind a proxy, every connection comes from the proxy's address, so the per-IP limit (MAX_CONNECTIONS_PER_IP) would treat all users as one. TRUST_PROXY tells the gateway where to find the real client IP:

Setup Setting Client IP taken from
Your own proxy (nginx, Caddy, Traefik, …) TRUST_PROXY=1 The last X-Forwarded-For entry, the one your proxy added. Earlier entries come from the browser and are ignored.
Cloudflare Tunnel, or Cloudflare's proxy in front (also in front of your own proxy) TRUST_PROXY=cloudflare CF-Connecting-IP, which Cloudflare always sets itself.

Only set it if the gateway can only be reached through the proxy (don't publish the gateway's port to the internet, or bind it to 127.0.0.1). Otherwise anyone can send these headers directly and pick their own "IP".

TRUST_PROXY=cloudflare exists since v0.16.0-beta.3. Older gateways read CF-Connecting-IP and the first X-Forwarded-For entry with TRUST_PROXY=1, which clients can fake behind a non-Cloudflare proxy, so update if you use one.

Also set ALLOWED_ORIGINS to your public address so other websites can't use your gateway.

nginx

server {
    listen 443 ssl;
    server_name voice.example.com;
    ssl_certificate     /etc/letsencrypt/live/voice.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/voice.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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_read_timeout 1h;
    }
}

Use TRUST_PROXY=1 with this.

Caddy

voice.example.com {
    reverse_proxy 127.0.0.1:8080
}

Caddy gets the certificate automatically and proxies WebSockets and X-Forwarded-For without extra settings. Use TRUST_PROXY=1.

Traefik (Docker labels)

services:
  webspeak3:
    image: moepchi/webspeak3:latest
    restart: unless-stopped
    environment:
      - TRUST_PROXY=1
      - ALLOWED_ORIGINS=https://voice.example.com
    labels:
      - traefik.enable=true
      - traefik.http.routers.webspeak3.rule=Host(`voice.example.com`)
      - traefik.http.routers.webspeak3.entrypoints=websecure
      - traefik.http.routers.webspeak3.tls.certresolver=letsencrypt
      - traefik.http.services.webspeak3.loadbalancer.server.port=8080

Traefik handles WebSockets and X-Forwarded-For automatically.

Cloudflare Tunnel

The repository's docker-compose.yml already includes a cloudflared service. Point the tunnel's public hostname at http://webspeak3:8080, put the token in .env as CLOUDFLARE_TUNNEL_TOKEN, and keep TRUST_PROXY=cloudflare. Cloudflare sets CF-Connecting-IP itself, and the gateway publishes no port, so nothing else can reach it.

Client and gateway on different hosts

If the web client is hosted somewhere else (a static host or CDN) and only the gateway runs on your server:

  • Build the client with VITE_GATEWAY_URL=wss://gateway.example.com (the /ws path is added automatically). It must be wss:// if the page is HTTPS.
  • Set WEB_STATIC=0 on the gateway if it shouldn't serve the client too.
  • Set ALLOWED_ORIGINS to the client's address, e.g. https://client.example.com.
  • Features that rely on the gateway serving the page (runtime DEFAULT_SERVER, the SERVER_PICKER dropdown) don't apply then. Use the build-time variables instead; see Configuration & Environment Variables.

Checking it works

  • https://voice.example.com/healthz shows ok.
  • The page loads and connecting to a server works. If the page loads but connecting hangs or fails right away, the /ws WebSocket isn't being passed through.
  • The browser asks for microphone access. If it doesn't, the page isn't on HTTPS.

Clone this wiki locally