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
Hostheader (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 highproxy_read_timeout.
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.
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.