Skip to main content

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).

Whichever proxy you use, it has to pass WebSockets through, forward the client’s address and accept large uploads.

  1. Pass WebSockets through (Upgrade/Connection headers) for /api/v1/ws and for the voice signaling under /rtc (/rtc, /rtc/v1; the HTTP checks /rtc/validate and /rtc/v1/validate are plain GETs). Use a long read timeout (an hour): both are long-lived connections.
  2. Set X-Forwarded-For. tavrn only trusts the header when the connection comes from an address in TAVRN_TRUSTED_PROXIES (default: loopback only; deploy/docker-compose.yml adds 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.
  3. Allow large bodies, at least as large as TAVRN_UPLOAD_MAX_BYTES (or TAVRN_UPLOAD_MAX_BYTES_CEILING when grants raise it).
  4. Leave requests and CORS to the node: pass Authorization and the Tavrn-* request headers through unchanged (also on the /rtc WebSocket upgrade), answer OPTIONS preflights from the node, and do not add, replace or strip Access-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).

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).

ProxyExampleNotes
Caddydeploy/Caddyfile.exampleAutomatic 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.
nginxdeploy/nginx.conf.exampleproxy_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 ManagerbelowThe same nginx settings through its web interface.
Traefik (v3)deploy/traefik.yml.exampleA 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.exampleProxyPass … 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.exampleoption 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 is configured in its web interface with one proxy host and two advanced settings.

Create a proxy host:

FieldValue
Domain Nameschat.example.org
Schemehttp
Forward Hostnametavrn (container on the same Docker network) or the host IP
Forward Port8080
Websockets Supporton (covers /api/v1/ws and /rtc)
Block Common Exploitson
SSLrequest 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.

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:

Terminal window
TAVRN_DOMAIN=example.org # addresses alice@example.org
TAVRN_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 later

The 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:

  1. Reverse-proxy rule (preferred): forward /.well-known/tavrn of the website to the node. The document always carries the current node keys, so key rotation needs no further step.

    nginx (in the server block of example.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 machine
    proxy_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.

  2. Static copy: docker compose exec tavrn tavrn-node well-known > well-known.json prints the document; serve it as /.well-known/tavrn with Content-Type: application/json, Access-Control-Allow-Origin: * and Cache-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.

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:

DNS records
_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.

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).

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):

Terminal window
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.

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 (and turn.<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, localhost and every label starting with xn-- unless it is valid IDNA.
  • Impersonation block list (case-insensitive, also with digits for letters like 0/o, 1/l and 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, so support.example.org would 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).

Four requests from outside show whether discovery, readiness, health and the voice signaling get through the proxy.

Terminal window
curl -s https://chat.example.org/.well-known/tavrn | jq .domain # discovery reachable
curl -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.