Setup

Behind a reverse proxy

Run Tyto behind an existing reverse proxy — plain HTTP mode, ports, WebSockets, SSE and voice.

Both stacks bundle Caddy with automatic HTTPS and want ports 80/443. When those ports already belong to a reverse proxy on the same host (nginx, Traefik, Caddy, a NAS ingress), switch Tyto to plain-HTTP mode and let your proxy terminate TLS.

Core

Add to the core .env:

BEHIND_PROXY=true
HTTP_PORT=127.0.0.1:8080
HTTPS_PORT=127.0.0.1:8443

SERVER_DOMAIN stays your real public domain — all URLs still derive from it and requests are host-matched. BEHIND_PROXY=true disables certificate issuance and serves plain HTTP; HTTP_PORT picks the host port your proxy forwards to. Bind it to 127.0.0.1 when the proxy runs on the same host so the plain-HTTP backend isn’t exposed on every interface — use 0.0.0.0:8080 only when the proxy is on another machine. HTTPS_PORT must move too: the stack publishes 443 (TCP and UDP) unconditionally, so leaving it at the default collides with the proxy and docker compose up -d fails to start. TRUSTED_PROXIES defaults to private_ranges so client IPs and scheme resolve from the proxy’s forwarded headers — set your proxy’s exact CIDR to narrow it.

Client

The same three lines in the client .env — it publishes 443 the same way, so it needs its own free ports:

BEHIND_PROXY=true
HTTP_PORT=127.0.0.1:8081
HTTPS_PORT=127.0.0.1:8444

Single-stack bundle

Running the single-stack bundle instead? There is no client stack, so only the core config applies — plus the two bundle variables:

SERVER_DOMAIN=chat.example.com
APP_DOMAIN=app.example.com
CORS_ALLOW_ORIGIN=https://app.example.com
BEHIND_PROXY=true
HTTP_PORT=127.0.0.1:8080
HTTPS_PORT=127.0.0.1:8443
docker compose -f compose.yaml -f compose.bundle.yaml up -d

Both domains ride the same HTTP_PORT — the bundled Caddy tells them apart by the Host header. Point both domains at that one backend port:

chat.example.com {
	reverse_proxy localhost:8080
}
app.example.com {
	reverse_proxy localhost:8080
}

For nginx, one server block with both names (or two blocks) proxying to the same 127.0.0.1:8080 works the same way — just keep proxy_set_header Host $host; so the backend can tell the domains apart.

What the proxy must do

  • Forward the original Host header (requests are matched by domain).
  • Support WebSocket upgrade on the API domain — real-time and the voice signal ride the API origin (/rtc).
  • Be SSE-friendly on the API domain: response buffering off and a long read timeout (Mercure keeps connections open). For nginx: proxy_buffering off; and a high proxy_read_timeout.
Tip

A host-level Caddy needs none of the extra tuning — its defaults handle WebSockets and SSE.

chat.example.com {
	reverse_proxy localhost:8080
}
app.example.com {
	reverse_proxy localhost:8081
}

For nginx, a minimal API-domain location block:

location / {
	proxy_pass http://127.0.0.1:8080;
	proxy_set_header Host $host;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_set_header X-Forwarded-Proto $scheme;
	proxy_http_version 1.1;
	proxy_set_header Upgrade $http_upgrade;
	proxy_set_header Connection $connection_upgrade;
	proxy_buffering off;
	proxy_read_timeout 24h;
}

($connection_upgrade comes from the standard map $http_upgrade $connection_upgrade block in nginx.conf.)

Using Nginx Proxy Manager: the defaults buffer SSE, so the connection opens but no events arrive until a refresh. On the API host enable Websockets Support, then add under Advanced → Custom Nginx Configuration:

proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 24h;
send_timeout 24h;

Voice media bypasses the proxy

Only the voice signal (WebSocket) goes through the proxy. The media itself is WebRTC: the server advertises its public IP via ICE and browsers connect directly — 7881/tcp + 50000–50100/udp must reach the core host, no proxy involved (no L7 proxy can carry WebRTC media; this is true of every voice platform). If those ports can’t be opened, set VOICE_ENABLED=false for a text-only server.

Warning

If your goal is hiding the origin IP (e.g. behind a CDN), voice leaks it — ICE candidates contain the host’s real public IP. Text-only mode keeps the origin hidden.

Media is still encrypted end-to-end at the transport level (DTLS-SRTP); it does not need TLS certificates or a domain.