Reverse proxy and TLS
tavrn speaks plain HTTP only (default port 8080). TLS, certificates and HTTP/2 are always handled by a reverse
proxy in front of it. This is mandatory, not optional: nodes only federate over HTTPS (the identity domain’s
/.well-known/tavrn and the endpoints it names, see delegation).
What the proxy must do
Section titled “What the proxy must do”Whichever proxy you use, it has to pass WebSockets through, forward the client’s address and accept large uploads.
- Pass WebSockets through (
Upgrade/Connectionheaders) for/api/v1/wsand for the voice signaling under/rtc(/rtc,/rtc/v1; the HTTP checks/rtc/validateand/rtc/v1/validateare plain GETs). Use a long read timeout (an hour): both are long-lived connections. - Set
X-Forwarded-For. tavrn only trusts the header when the connection comes from an address inTAVRN_TRUSTED_PROXIES(default: loopback only;deploy/docker-compose.ymladds Docker’s private ranges). If your proxy connects from anywhere else, add its address, otherwise rate limits and bans apply to the proxy address instead of the client. List only proxies: a client that connects from a listed address may name any address in the header. - Allow large bodies, at least as large as
TAVRN_UPLOAD_MAX_BYTES(orTAVRN_UPLOAD_MAX_BYTES_CEILINGwhen grants raise it). - Leave requests and CORS to the node: pass
Authorizationand theTavrn-*request headers through unchanged (also on the/rtcWebSocket upgrade), answerOPTIONSpreflights from the node, and do not add, replace or stripAccess-Control-*headers.
The node answers CORS for every route itself: any origin, no credentials (it uses bearer tokens, never cookies),
the request headers a preflight names are mirrored back, and preflights are cached for 10 minutes. Two kinds of
clients depend on it: web clients of other nodes that act on this node for their users, and the desktop app, whose
pages have their own origin, so every request it sends is cross-origin, including the uploads with their Tavrn-*
headers. A proxy that answers preflights itself with a fixed header list breaks those clients (“network error”)
while the node’s own web client, which is same-origin, keeps working.
Compression is not needed in the proxy: the node sends the web client’s files compressed itself (gzip, or a
.br/.gz copy shipped with the build, whichever the browser accepts) with an ETag, and API answers are better
left uncompressed (they can carry secrets next to attacker-chosen text, the BREACH pattern). If you enable it
anyway, limit it to the static files.
Also recommended: redirect HTTP to HTTPS, send HSTS, and block /metrics for the public
(Metrics and health). Route /rtc only to tavrn, never to LiveKit’s port 7880 (which is
not published at all). /api/v1/voice/webhook receives LiveKit’s events from inside the container; requests
from outside are rejected unless signed with the node’s LiveKit secret, so the proxy does not need to block it
(it may).
Examples
Section titled “Examples”Any reverse proxy that meets the list above works. The repository ships complete examples; each one terminates TLS,
redirects HTTP to HTTPS, sends HSTS, passes the WebSockets through, forwards the client’s address, allows 100 MB
bodies and keeps /metrics off the public internet. Replace chat.example.org and the upstream address
(127.0.0.1:8080 for a published port, tavrn:8080 on a shared Docker network).
| Proxy | Example | Notes |
|---|---|---|
| Caddy | deploy/Caddyfile.example | Automatic certificates. Caddy passes WebSockets and X-Forwarded-For by itself; the example adds HSTS (Caddy sends none by default), the body limit and respond /metrics 404. |
| nginx | deploy/nginx.conf.example | proxy_pass with Host, X-Forwarded-For, X-Forwarded-Proto, the WebSocket headers (Upgrade, Connection "upgrade"), proxy_read_timeout 3600s, client_max_body_size 100m, HSTS, HTTP/2 and the redirect. Certificates e.g. via certbot. |
| Nginx Proxy Manager | below | The same nginx settings through its web interface. |
| Traefik (v3) | deploy/traefik.yml.example | A dynamic configuration (file provider) with the router, an HSTS middleware, the HTTPS redirect and an ipAllowList for /metrics; the ACME resolver and entry points belong to the static configuration (commented at the end). Traefik sets the forwarded headers itself and has no body limit unless the buffering middleware is used. The same routers and middlewares work as Docker labels. |
| Apache httpd (2.4.47+) | deploy/apache.conf.example | ProxyPass … upgrade=websocket timeout=3600 (mod_proxy_http tunnels the WebSockets, no mod_proxy_wstunnel needed), ProxyPreserveHost On, X-Forwarded-Proto, LimitRequestBody, HSTS, Require ip on /metrics. |
| HAProxy (2.x) | deploy/haproxy.cfg.example | option forwardfor, timeout tunnel 1h for the WebSockets, the redirect, HSTS and a deny for /metrics from outside; HAProxy streams bodies and limits nothing. |
Several instances: list every instance as an upstream (nginx upstream, Caddy reverse_proxy with several
upstreams, a Traefik service with several servers, HAProxy server lines), see Load balancer.
Nginx Proxy Manager
Section titled “Nginx Proxy Manager”Nginx Proxy Manager is configured in its web interface with one proxy host and two advanced settings.
Create a proxy host:
| Field | Value |
|---|---|
| Domain Names | chat.example.org |
| Scheme | http |
| Forward Hostname | tavrn (container on the same Docker network) or the host IP |
| Forward Port | 8080 |
| Websockets Support | on (covers /api/v1/ws and /rtc) |
| Block Common Exploits | on |
| SSL | request a Let’s Encrypt certificate, enable Force SSL, HTTP/2 and HSTS |
Under “Advanced” add: client_max_body_size 100m; and proxy_read_timeout 3600s;
If the proxy shares a Docker network with the tavrn container, tavrn does not need a published TCP port (remove
the 8080 entry in docker-compose.yml and attach the tavrn service to the proxy’s network). The media ports (UDP
range, TCP 7881, optionally TURN) stay published on the host. The same applies to any proxy that runs as a container.
TURN over TLS through the proxy
Section titled “TURN over TLS through the proxy”With TAVRN_RTC_TURN_ENABLED=true and TAVRN_RTC_TURN_TLS_PORT (default 5349), clients connect to
turns:<TAVRN_RTC_TURN_DOMAIN>:443 (default turn.<host of TAVRN_PUBLIC_URL>). tavrn never terminates TLS, so the
proxy does: give the TURN domain its own DNS record pointing at the proxy, a certificate for it, and a TCP
stream that terminates TLS and forwards plain TCP to the container’s TLS port (publish it on 127.0.0.1 only,
see the commented line in docker-compose.yml). Port 443 of the TURN domain must not be an HTTP virtual host at
the same time.
nginx:
stream { server { listen 443 ssl; # a separate IP, or use ssl_preread to share 443 (below) ssl_certificate /etc/letsencrypt/live/turn.chat.example.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/turn.chat.example.org/privkey.pem; proxy_pass 127.0.0.1:5349; proxy_timeout 1h; }}To share port 443 with the web server, route by name with ssl_preread in a stream block in front: the TURN name
goes to a TLS-terminating stream server on an internal port, everything else to the HTTPS server. Clients always use
port 443 (LiveKit announces turns:<domain>:443). Nginx Proxy Manager: a Stream (TCP) listening on 443 of an
address its proxy hosts do not use, with an SSL certificate for the TURN domain (stream SSL needs a recent NPM
release), forwarding to tavrn:5349. Traefik: a TCP router with HostSNI of the TURN domain and tls: {} to a TCP
service on tavrn:5349. HAProxy: a listen in mode tcp with bind :443 ssl crt <turn certificate> on the TURN
address and server tavrn 127.0.0.1:5349. Without a TLS-terminating stream proxy, leave TAVRN_RTC_TURN_TLS_PORT=0
and use TURN over UDP. The rest of TURN is in Voice and video.
Identity domain and node host (delegation)
Section titled “Identity domain and node host (delegation)”The identity of a node is its domain (TAVRN_DOMAIN): addresses alice@example.org, node keys, tokens.
The node itself may run on another host (TAVRN_PUBLIC_URL, default https://<TAVRN_DOMAIN>), for example because
the domain already carries a website:
TAVRN_DOMAIN=example.org # addresses alice@example.orgTAVRN_PUBLIC_URL=https://chat.example.org # where the web client and the API live# TAVRN_WEBAUTHN_RP_ID=example.org # the default; passkeys keep working if the app host moves laterThe node serves its discovery document on its own host as usual; it names domain: example.org and the
endpoints on chat.example.org. Clients and other nodes always resolve the identity domain first:
https://example.org/.well-known/tavrn. So the website on example.org has to answer that one path (CORS-open,
application/json). There are two ways:
-
Reverse-proxy rule (preferred): forward
/.well-known/tavrnof the website to the node. The document always carries the current node keys, so key rotation needs no further step.nginx (in the
serverblock ofexample.org):location = /.well-known/tavrn {proxy_pass https://chat.example.org; # or http://127.0.0.1:8080 when the node runs on the same machineproxy_set_header Host chat.example.org;proxy_ssl_server_name on;}Caddy:
Caddyfile example.org {handle /.well-known/tavrn {reverse_proxy https://chat.example.org {header_up Host chat.example.org}}# … the website}Do not redirect instead of proxying: nodes follow redirects only on the same host, browsers not at all.
-
Static copy:
docker compose exec tavrn tavrn-node well-known > well-known.jsonprints the document; serve it as/.well-known/tavrnwithContent-Type: application/json,Access-Control-Allow-Origin: *andCache-Control: max-age=300. Caveat: the copy must be refreshed whenever the node keys change (rotation, compromise) and whenever settings in it change (name, registration, policies, capabilities). Peers cache it for at most an hour and refuse tokens signed with a key they do not find in it, so a stale copy breaks federation until it is updated. Prefer the proxy rule.
Check: curl -s https://example.org/.well-known/tavrn | jq '.domain, .api' must print "example.org" and
"https://chat.example.org/api/v1".
Rules the node enforces (FEDERATION.md §1.3, §1.4): the document’s domain must equal the queried domain; the
endpoints must be https (and are checked against private addresses like the domain itself); peers cache them as
long as the document (5 minutes to 1 hour), and a cached endpoint that stops answering is resolved again at once
(at most once a minute), so a move takes effect at the next failure.
SRV record instead of the well-known file
Section titled “SRV record instead of the well-known file”When the website on the identity domain cannot serve the well-known file, a DNS SRV record can point to the node’s host instead.
You need EITHER the well-known on the identity domain OR a DNS record, never both:
_tavrn._tcp.example.org. 3600 IN SRV 10 0 443 chat.example.org.Resolution order (FEDERATION.md §1.3 has the decision table): https://example.org/.well-known/tavrn first; when
the website has no such file (404) or there is no HTTPS server on example.org at all, the SRV record
(TAVRN_FEDERATION_SRV_FALLBACK, default on): other nodes connect to chat.example.org port 443 but ask for
https://example.org:443/.well-known/tavrn (TLS server name and Host are example.org), and the document must
name example.org; only then, after a 404, the direct https://example.org/api/v1. Other HTTP errors (5xx, 403, …)
are errors. Browsers cannot query DNS records, so web clients ask the node they were loaded from
(GET /api/v1/federation/resolve/example.org), which runs the same order: the connect flow, invites and add links
work with an SRV-only setup too.
The SRV target must serve a certificate valid for the identity domain (example.org), not only for its own
name: a SAN entry for example.org (or a wildcard that covers it) on the certificate of chat.example.org
suffices, and the reverse proxy must answer requests with Host: example.org on that port. A certificate valid only
for chat.example.org is refused. The reason: DNS answers can be spoofed on the network path, and a spoofed SRV
record must not be enough to impersonate your node; a certificate for the identity domain is. The conformance and
key-consistency checks still apply, and a document that suddenly comes from the SRV path with none of the known
keys is treated as a key conflict (peers block the node until their operators unblock it), so keep at least one
old key published while you move between the well-known file and an SRV record.
Passkeys
Section titled “Passkeys”The relying party id is TAVRN_WEBAUTHN_RP_ID (default TAVRN_DOMAIN), the origin the public URL. The id must
be the origin’s host or a parent domain of it: example.org covers chat.example.org, so with the default a
later move to app.example.org keeps every passkey. A public URL on an unrelated host (chat.example.net for
example.org) needs TAVRN_WEBAUTHN_RP_ID=chat.example.net; the node refuses to start otherwise. Changing the
relying party id later invalidates all registered passkeys (users sign in with TOTP or a recovery code and
register them again). The node publishes the id as webauthn.rp_id (GET /api/v1/node, /.well-known/tavrn), so
clients use it for the assertions they build themselves (unlocking the secret storage with a passkey).
Passkeys in the apps
Section titled “Passkeys in the apps”A browser puts the page’s origin (the public URL) into every passkey ceremony. The Android app (Android 14 and
newer) runs passkeys through Android’s Credential Manager, which puts the app’s origin there instead:
android:apk-key-hash:<hash>, the base64url (no padding) of the SHA-256 of the certificate the APK is signed with. The node refuses those ceremonies unless the origin is in
TAVRN_WEBAUTHN_APP_ORIGINS; the app then signs in through the browser (as before) and says that the node does not
accept the app’s passkeys yet. With the origin configured, registering a passkey, signing in with it and
unlocking the encrypted messages with it work inside the app. The relying party id does not change, so a passkey
made in the app also works in the browser and the other way round.
The node publishes every accepted origin as webauthn.origins (GET /api/v1/node, /.well-known/tavrn), the web
origin first; the app compares its own origin with that list. check-config prints the setting, and the node
refuses to start with an entry that is not android:apk-key-hash: and 43 base64url characters.
Deriving the value from the certificate’s SHA-256 fingerprint (hex, with or without colons, as keytool -list -v,
apksigner verify --print-certs or the release workflow’s ANDROID_CERT_SHA256 print it):
fp='<the SHA-256 fingerprint, 64 hex digits, e.g. AB:CD:…>'printf '%s' "$fp" | tr -d ': ' | xxd -r -p | base64 -w0 | tr '+/' '-_' | tr -d '='; echo# prints 43 characters → TAVRN_WEBAUTHN_APP_ORIGINS=android:apk-key-hash:<those 43 characters>Android also requires the relying party’s domain to vouch for the app: https://<relying party id>/.well-known/ assetlinks.json must list the app’s package with delegate_permission/common.get_login_creds and the same
fingerprint (there in the colon-separated hex form). Every signing key is its own origin: list the release key, and
add a debug or a store’s re-signing key only where such builds should use passkeys. Removing an origin later only
stops new ceremonies from that app; registered passkeys keep working in the browser.
The desktop apps need no entry: they run passkeys through the operating system (Windows) with the node’s web origin, or through the browser.
Everything else that builds absolute URLs (mail links, invite and application add links, the OIDC redirect URI
<public URL>/api/v1/auth/oidc/callback, the LiveKit signaling URL, turn.<public host>) uses the public URL.
Hosted nodes under one domain
Section titled “Hosted nodes under one domain”A hosting service that runs nodes as <name>.example.org gives every node its own identity domain
(TAVRN_DOMAIN=<name>.example.org, TAVRN_PUBLIC_URL=https://<name>.example.org, the default) and its own
well-known on its own host. They never collide with the delegation of example.org itself (another domain,
another document, other keys).
The node needs nothing special; the hosting service must enforce, before it creates a node name:
- Reserved labels:
www,app,api,turn(andturn.<label>names for TURN over TLS),mail,smtp,imap,mx,ns1/ns2,cdn,static,assets,status,docs,blog,git,matrix,identity,auth,login,sso,id,account,accounts,well-known,localhostand every label starting withxn--unless it is valid IDNA. - Impersonation block list (case-insensitive, also with digits for letters like
0/o,1/land with hyphens removed):admin,administrator,root,support,help,helpdesk,security,abuse,postmaster,hostmaster,webmaster,noreply,official,staff,team,moderator,mod,system,tavrn,billing,payment,verify,verification, plus the names of well-known brands and of other chat services. Users see the domain as part of every address, sosupport.example.orgwould read as the hosting service’s own support. - Labels follow the node domain rules (lowercase
a-z0-9-, 1–63 characters, no leading or trailing hyphen) and are never reused after a node is deleted (addresses and keys of the old node stay in other nodes’ caches).
Verify the proxy
Section titled “Verify the proxy”Four requests from outside show whether discovery, readiness, health and the voice signaling get through the proxy.
curl -s https://chat.example.org/.well-known/tavrn | jq .domain # discovery reachablecurl -s https://chat.example.org/readyz # {"ready":true,"database":true,"livekit":true,"cache":null}curl -s https://chat.example.org/healthz # {"status":"ok","voice":"running"}curl -s -o /dev/null -w '%{http_code}\n' https://chat.example.org/rtc/validate # 401/rtc/validate must answer 401 with the code voice.token_invalid (the proxy reaches tavrn, tavrn refuses
without a token).
This page is generated from tavrn-node/docs/HANDBOOK.md (AGPL-3.0-only) when the website is built; its links were adapted for this site.