mysite/public/posts/matrix_setup/index.html

181 lines
No EOL
91 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html><html lang=en dir=auto><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1,shrink-to-fit=no"><meta name=robots content="index, follow"><title>Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) | AlipourIm journeys</title>
<meta name=keywords content="matrix,synapse,element-call,livekit,coturn,nginx,webrtc,self-hosting,debugging"><meta name=description content="How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls."><meta name=author content="Iman Alipour"><link rel=canonical href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/><link crossorigin=anonymous href=/assets/css/stylesheet.51e3b550fcc0c3f3a10e2d7b70445c6cb21171bed36521d66dc7427208cafbf9.css integrity="sha256-UeO1UPzAw/OhDi17cERcbLIRcb7TZSHWbcdCcgjK+/k=" rel="preload stylesheet" as=style><link rel=icon href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon.ico><link rel=icon type=image/png sizes=16x16 href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-16x16.png><link rel=icon type=image/png sizes=32x32 href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-32x32.png><link rel=apple-touch-icon href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/apple-touch-icon.png><link rel=mask-icon href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/safari-pinned-tab.svg><meta name=theme-color content="#2e2e33"><meta name=msapplication-TileColor content="#2e2e33"><link rel=alternate hreflang=en href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/><noscript><style>#theme-toggle,.top-link{display:none}</style><style>@media(prefers-color-scheme:dark){:root{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--theme)}.list:not(.dark)::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-thumb{border-color:var(--theme)}}</style></noscript><meta property="og:url" content="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/"><meta property="og:site_name" content="AlipourIm journeys"><meta property="og:title" content="Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"><meta property="og:description" content="How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls."><meta property="og:locale" content="en"><meta property="og:type" content="article"><meta property="article:section" content="posts"><meta property="article:published_time" content="2025-08-26T00:00:00+00:00"><meta property="article:modified_time" content="2025-08-26T00:00:00+00:00"><meta property="article:tag" content="Matrix"><meta property="article:tag" content="Synapse"><meta property="article:tag" content="Element-Call"><meta property="article:tag" content="Livekit"><meta property="article:tag" content="Coturn"><meta property="article:tag" content="Nginx"><meta property="og:image" content="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/images/matrix-cover.png"><meta name=twitter:card content="summary_large_image"><meta name=twitter:image content="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/images/matrix-cover.png"><meta name=twitter:title content="Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"><meta name=twitter:description content="How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls."><script type=application/ld+json>{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Posts","item":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/"},{"@type":"ListItem","position":2,"name":"Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)","item":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/"}]}</script><script type=application/ld+json>{"@context":"https://schema.org","@type":"BlogPosting","headline":"Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)","name":"Self-hosting Matrix \u002b Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)","description":"How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.","keywords":["matrix","synapse","element-call","livekit","coturn","nginx","webrtc","self-hosting","debugging"],"articleBody":" TL;DR: The call kept saying “waiting for media” because the browser never opened a WebSocket to LiveKit. The root cause was duplicate Access-Control-Allow-Origin headers on /sfu/get (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP 101 in logs) solved it.\nWhat Im building A Matrix homeserver (Synapse) at matrix.example.com (replace with your domain). TURN/STUN (coTURN) for NAT traversal. Element Call backed by LiveKit, fronted by rtc.example.com. Nginx (host) as the single reverse proxy for everything. Cloudflare DNS (with rtc.* set to DNS-only, no orange cloud). UFW firewall opened for Matrix federation, TURN, and LiveKit media ports. I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used host Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.\nPrereqs DNS A/AAAA: matrix.example.com → your server (v4/v6) rtc.example.com → your server (v4/v6) Certificates: matrix.example.com and rtc.example.com via Lets Encrypt on the host Cloudflare: DNS-only (grey cloud) for rtc.example.com so WebSockets \u0026 UDP work without interference. UFW / firewall open: 80/tcp, 443/tcp 8448/tcp (Matrix federation) 3478/tcp, 3478/udp and 5349/tcp (TURN/TLS) LiveKit: 7881/tcp and 5010050200/udp (or your chosen range) Docker + docker compose installed. Synapse + PostgreSQL 1) The PostgreSQL collation gotcha Synapse prefers the database collation C. If your Postgres cluster was initialized with en_US.utf8, Synapse will error like:\nD a t a b a s e h a s i n c o r r e c t c o l l a t i o n o f ' e n _ U S . u t f 8 ' . S h o u l d b e ' C ' Two ways to resolve:\nPreferred (clean): Re-initialize the Postgres cluster with C and UTF-8: # docker-compose.yml (excerpt for Postgres) services: db: image: postgres:16 environment: POSTGRES_DB: synapse POSTGRES_USER: synapse POSTGRES_PASSWORD: POSTGRES_INITDB_ARGS: \"--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C\" volumes: - ./pgdata:/var/lib/postgresql/data Requires wiping the volume and recreating the DB.\nPragmatic (works quickly): In Synapses DB config, set allow_unsafe_locale: true. This bypasses the check. Its fine for hobby use; for production, prefer the clean C cluster. 2) Start Synapse and generate config docker compose up -d db synapse # Logs docker compose logs --tail=200 synapse Ensure Synapse prints your server_name and public base URL and stays up.\n3) Create an admin user # Exec into the running Synapse container: docker compose exec synapse register_new_matrix_user \\ -c /data/homeserver.yaml -u -p \\ -a -k If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use docker compose exec synapse … against the running container.\nTURN (coTURN) 1) Avoid bad inline comments If you see errors like:\nE R R O R : U n k n o w n b o o l e a n v a l u e : # l o g t o j o u r n a l d / s y s l o g . Y o u c a n u s e o n / o f f , y e s / n o , 1 / 0 , t r u e / f a l s e . …it means a # comment is on the same line as a boolean directive. Move comments to their own lines.\n2) Minimal turnserver.conf listening-port=3478 tls-listening-port=5349 fingerprint use-auth-secret static-auth-secret= # also set in Synapse realm=example.com # used in creds generation total-quota=0 bps-capacity=0 cli-password= no-cli cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem # If behind NAT: # external-ip=/ 3) Wire TURN into Synapse In homeserver.yaml:\nturn_uris: - \"turn:turn.example.com?transport=udp\" - \"turn:turn.example.com?transport=tcp\" - \"turns:turn.example.com:5349?transport=tcp\" turn_shared_secret: \"\" turn_user_lifetime: \"1d\" Verify the homeserver issues TURN creds:\nTOKEN='' curl -s -H \"Authorization: Bearer $TOKEN\" \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq You should see uris, a time-limited username and password.\nNginx (host) for Synapse and federation Create /etc/nginx/conf.d/matrix.conf:\n# Client traffic on 443 server { listen 443 ssl; listen [::]:443 ssl; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; # Proxy client \u0026 admin APIs to Synapse (container) on 8008 location ~ ^(/_matrix|/_synapse/client) { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } # Advertise homeserver base + RTC focus (MSC4143) via .well-known location = /.well-known/matrix/client { default_type application/json; add_header Access-Control-Allow-Origin \"*\" always; return 200 '{\"m.homeserver\":{\"base_url\":\"https://matrix.example.com\"},\"org.matrix.msc4143.rtc_foci\":[{\"type\":\"livekit\",\"livekit_service_url\":\"https://rtc.example.com\"}]}'; } } # Federation on 8448 server { listen 8448 ssl http2; listen [::]:8448 ssl http2; server_name matrix.example.com; ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; client_max_body_size 50M; } } Reload and sanity check:\nsudo nginx -t \u0026\u0026 sudo systemctl reload nginx curl -s https://matrix.example.com/.well-known/matrix/client | jq Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:\ncurl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features.\"org.matrix.msc4140\"' # expect: true Element Call + LiveKit Ill run LiveKit and the small JWT helper (Elements elementcall_jwt) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.\n1) LiveKit config (/etc/livekit.yaml inside container) port: 7880 bind_addresses: [\"0.0.0.0\"] rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 use_external_ip: true logging: level: info turn: enabled: false keys: lk_prod_1: \"REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________\" Important: the secret must be \u003e= 32 chars. The default devkey will trigger secret is too short warnings and wont work with the JWT helper.\n2) elementcall_jwt env Run it with:\nLIVEKIT_URL=wss://rtc.example.com (root WS URL, no /livekit/sfu path) LIVEKIT_KEY=lk_prod_1 LIVEKIT_SECRET= LIVEKIT_JWT_PORT=8080 (internal HTTP port the proxy will hit) Optionally: LIVEKIT_FULL_ACCESS_HOMESERVERS=* during setup Check logs on start; it prints the LIVEKIT_URL it will advertise.\n3) Nginx (host) for rtc.example.com Create /etc/nginx/conf.d/rtc.conf:\nserver { listen 443 ssl; listen [::]:443 ssl; server_name rtc.example.com; ssl_certificate /etc/letsencrypt/live/rtc.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/rtc.example.com/privkey.pem; access_log /var/log/nginx/rtc.access.log combined; # 3a) JWT endpoint with clean CORS (avoid duplicate ACAO) location = /sfu/get { proxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \"Origin\" always; add_header Access-Control-Allow-Methods \"POST, OPTIONS\" always; add_header Access-Control-Allow-Headers \"Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization\" always; if ($request_method = OPTIONS) { return 204; } proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8070/sfu/get; # elementcall_jwt } # 3b) LiveKit WS \u0026 HTTP (catch-all) location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \"upgrade\"; proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; # \"livekit\" proxy_set_header Origin $http_origin; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 3600s; proxy_pass http://127.0.0.1:7880; # livekit } } Reload and basic checks:\nsudo nginx -t \u0026\u0026 sudo systemctl reload nginx # JWT preflight (should return a single ACAO header) curl -si -X OPTIONS https://rtc.example.com/sfu/get \\ -H 'Origin: https://app.element.io' \\ -H 'Access-Control-Request-Method: POST' \\ -H 'Access-Control-Request-Headers: authorization, content-type' | sed -n '1,30p' # Expected: one Access-Control-Allow-Origin and 200/204 Why I did this: I initially had two Access-Control-Allow-Origin headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.\nThe “waiting for media” debugging story (how I found it) Symptom: Element Call created rooms, but calls stayed on “waiting for media.”\nWhat worked:\nelementcall_jwt could CreateRoom in LiveKit (seen in logs). TURN creds endpoint returned time-limited credentials. What didnt appear:\nNo HTTP 101 lines in rtc.access.log → the browser never established a WebSocket to LiveKit. Step 1: prove the WS vhost works (even without auth) curl -v --http1.1 \\ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \\ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \\ -H 'Sec-WebSocket-Version: 13' \\ https://rtc.example.com/rtc -o /dev/null Result: I got a 401 (expected), but importantly I saw a log line in rtc.access.log. So Nginx WS proxying was fine.\nStep 2: check the browser console The smoking gun in DevTools:\nA F c e c t e c s h s - A C P o I n t c r a o n l n - o A t l l l o o w a - d O r h i t g t i p n s : c / a / n r n t o c t . e c x o a n m t p a l i e n . c m o o m r / e s f t u h / a g n e t o n d e u e o r t i o g i a n c . c e s s c o n t r o l c h e c k s . The browser refused the JWT call due to duplicated ACAO headers, so no token → no WebSocket connect.\nFix: in Nginx I added:\nproxy_hide_header Access-Control-Allow-Origin; add_header Access-Control-Allow-Origin $http_origin always; add_header Vary \"Origin\" always; …and ensured no other add_header created duplicates. After that, /sfu/get succeeded and the WebSocket to wss://rtc.example.com immediately followed (I saw HTTP 101 in the logs).\nStep 3: confirm LiveKit side Once the WS was up, LiveKit logs showed participants joining (not just RoomService.CreateRoom), and calls were established.\nUseful verification commands # Synapse features (expect MSC4140 true) curl -s https://matrix.example.com/_matrix/client/versions | jq '.unstable_features.\"org.matrix.msc4140\"' # Well-known with RTC focus curl -s https://matrix.example.com/.well-known/matrix/client | jq # TURN creds (with your access token) curl -s -H \"Authorization: Bearer $TOKEN\" \\ https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq # JWT health curl -si -X POST https://rtc.example.com/sfu/get # LiveKit simple HTTP probe curl -si https://rtc.example.com | head # Nginx logs (look for 101 Switching Protocols when a call starts) sudo tail -f /var/log/nginx/rtc.access.log | grep ' 101 ' Common pitfalls (I hit these so you dont have to) Host Nginx vs Docker Nginx: If you already run Nginx on the host, dont also bind 80/443/8448 in a Docker Nginx — youll get bind() ... already in use and restart loops. Use host Nginx to reverse proxy to containers. Nginx http2 directive: Old Nginx may not support the http2 directive on listen. Use listen 443 ssl; (and add http2 if your version supports it). Certificate name mismatch: Make sure rtc.example.coms vhost uses a certificate for that exact hostname (initially I had the matrix.* cert on rtc.* and curl complained). Postgres collation: Either initialize the cluster with C or use allow_unsafe_locale: true in Synapse DB config to get running quickly. CORS duplication on /sfu/get: Only ONE Access-Control-Allow-Origin header. If the upstream adds it too, use proxy_hide_header Access-Control-Allow-Origin; on the Nginx location. Cloudflare: Use DNS-only for rtc.*. Proxies can interfere with WS and UDP paths. Firewall: Open the LiveKit UDP range and TURN ports on both v4 and v6. Final checklist (print me) https://matrix.example.com/.well-known/matrix/client returns both m.homeserver.base_url and org.matrix.msc4143.rtc_foci pointing to https://rtc.example.com. /_matrix/client/versions shows \"org.matrix.msc4140\": true. /sfu/get preflight returns one Access-Control-Allow-Origin and 200/204. Starting a call creates HTTP 101 entries to wss://rtc.example.com in rtc.access.log. LiveKit logs show participants joining (not just CreateRoom). /voip/turnServer returns time-limited TURN credentials. Cloudflare set to DNS-only for rtc.*. UFW allows 7881/tcp and your LiveKit UDP range. Credits \u0026 tooling Matrix Synapse, coTURN, LiveKit, Element Call. curl, jq, docker compose logs, Nginx access logs. These are your best friends. The debugging breakthrough was catching CORS errors in the browser console and looking for HTTP 101 in Nginx logs. Happy calling! 🎉\n","wordCount":"1802","inLanguage":"en","image":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/images/matrix-cover.png","datePublished":"2025-08-26T00:00:00Z","dateModified":"2025-08-26T00:00:00Z","author":{"@type":"Person","name":"Iman Alipour"},"mainEntityOfPage":{"@type":"WebPage","@id":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/"},"publisher":{"@type":"Organization","name":"AlipourIm journeys","logo":{"@type":"ImageObject","url":"http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon.ico"}}}</script></head><body id=top><script>localStorage.getItem("pref-theme")==="dark"?document.body.classList.add("dark"):localStorage.getItem("pref-theme")==="light"?document.body.classList.remove("dark"):window.matchMedia("(prefers-color-scheme: dark)").matches&&document.body.classList.add("dark")</script><header class=header><nav class=nav><div class=logo><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/ accesskey=h title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a><div class=logo-switches><button id=theme-toggle accesskey=t title="(Alt + T)" aria-label="Toggle theme"><svg id="moon" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg><svg id="sun" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button></div></div><ul id=menu><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/ title=Posts><span>Posts</span></a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/ title=PhD_journey><span>PhD_journey</span></a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/ title=About><span>About</span></a></li></ul></nav></header><main class=main><article class=post-single><header class=post-header><div class=breadcrumbs><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/>Home</a>&nbsp;»&nbsp;<a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/>Posts</a></div><h1 class="post-title entry-hint-parent">Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)</h1><div class=post-description>How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.</div><div class=post-meta><span title='2025-08-26 00:00:00 +0000 UTC'>August 26, 2025</span>&nbsp;·&nbsp;9 min&nbsp;·&nbsp;Iman Alipour
<span class=post-meta-item><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/#isso-thread class=isso-comments-link>Comments</a></span></div></header><figure class=entry-cover><img loading=eager src=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/images/matrix-cover.png alt="Matrix, Signal but distributed"><figcaption>Distributed end-to-end encrypted chat platform</figcaption></figure><div class=post-content><blockquote><p>TL;DR: The call kept saying <strong>“waiting for media”</strong> because the browser never opened a WebSocket to LiveKit. The root cause was <strong>duplicate <code>Access-Control-Allow-Origin</code> headers</strong> on <code>/sfu/get</code> (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP <strong>101</strong> in logs) solved it.</p></blockquote><h2 id=what-im-building>What I&rsquo;m building<a hidden class=anchor aria-hidden=true href=#what-im-building>#</a></h2><ul><li>A <strong>Matrix homeserver</strong> (Synapse) at <code>matrix.example.com</code> (replace with your domain).</li><li><strong>TURN/STUN</strong> (coTURN) for NAT traversal.</li><li><strong>Element Call</strong> backed by <strong>LiveKit</strong>, fronted by <code>rtc.example.com</code>.</li><li><strong>Nginx (host)</strong> as the single reverse proxy for everything.</li><li><strong>Cloudflare</strong> DNS (with <code>rtc.*</code> set to <strong>DNS-only</strong>, no orange cloud).</li><li><strong>UFW</strong> firewall opened for Matrix federation, TURN, and LiveKit media ports.</li></ul><blockquote><p>I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used <strong>host</strong> Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.</p></blockquote><hr><h2 id=prereqs>Prereqs<a hidden class=anchor aria-hidden=true href=#prereqs>#</a></h2><ul><li>DNS A/AAAA:<ul><li><code>matrix.example.com</code> → your server (v4/v6)</li><li><code>rtc.example.com</code> → your server (v4/v6)</li></ul></li><li>Certificates:<ul><li><code>matrix.example.com</code> and <code>rtc.example.com</code> via Lets Encrypt on the host</li></ul></li><li>Cloudflare: <strong>DNS-only (grey cloud)</strong> for <code>rtc.example.com</code> so WebSockets & UDP work without interference.</li><li>UFW / firewall open:<ul><li>80/tcp, 443/tcp</li><li>8448/tcp (Matrix federation)</li><li>3478/tcp, 3478/udp and <strong>5349/tcp</strong> (TURN/TLS)</li><li><strong>LiveKit</strong>: 7881/tcp and <strong>5010050200/udp</strong> (or your chosen range)</li></ul></li><li>Docker + docker compose installed.</li></ul><hr><h2 id=synapse--postgresql>Synapse + PostgreSQL<a hidden class=anchor aria-hidden=true href=#synapse--postgresql>#</a></h2><h3 id=1-the-postgresql-collation-gotcha>1) The PostgreSQL collation gotcha<a hidden class=anchor aria-hidden=true href=#1-the-postgresql-collation-gotcha>#</a></h3><p>Synapse prefers the database collation <strong><code>C</code></strong>. If your Postgres cluster was initialized with <code>en_US.utf8</code>, Synapse will error like:</p><div class="goat svg-container"><svg font-family="Menlo,Lucida Console,monospace" viewBox="0 0 512 25"><g transform="translate(8,16)"><text text-anchor="middle" x="0" y="4" fill="currentcolor" style="font-size:1em">D</text><text text-anchor="middle" x="8" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="16" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="24" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="32" y="4" fill="currentcolor" style="font-size:1em">b</text><text text-anchor="middle" x="40" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="48" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="56" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="72" y="4" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="80" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="88" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="104" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="112" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="120" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="128" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="136" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="144" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="152" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="160" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="168" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="184" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="192" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="200" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="208" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="216" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="224" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="232" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="240" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="248" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="264" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="272" y="4" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="288" y="4" fill="currentcolor" style="font-size:1em">'</text><text text-anchor="middle" x="296" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="304" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="312" y="4" fill="currentcolor" style="font-size:1em">_</text><text text-anchor="middle" x="320" y="4" fill="currentcolor" style="font-size:1em">U</text><text text-anchor="middle" x="328" y="4" fill="currentcolor" style="font-size:1em">S</text><text text-anchor="middle" x="336" y="4" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="344" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="352" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="360" y="4" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="368" y="4" fill="currentcolor" style="font-size:1em">8</text><text text-anchor="middle" x="376" y="4" fill="currentcolor" style="font-size:1em">'</text><text text-anchor="middle" x="384" y="4" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="400" y="4" fill="currentcolor" style="font-size:1em">S</text><text text-anchor="middle" x="408" y="4" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="416" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="424" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="432" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="440" y="4" fill="currentcolor" style="font-size:1em">d</text><text text-anchor="middle" x="456" y="4" fill="currentcolor" style="font-size:1em">b</text><text text-anchor="middle" x="464" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="480" y="4" fill="currentcolor" style="font-size:1em">'</text><text text-anchor="middle" x="488" y="4" fill="currentcolor" style="font-size:1em">C</text><text text-anchor="middle" x="496" y="4" fill="currentcolor" style="font-size:1em">'</text></g></svg></div><p><strong>Two ways to resolve:</strong></p><ul><li><strong>Preferred (clean)</strong>: Re-initialize the Postgres <strong>cluster</strong> with <code>C</code> and <code>UTF-8</code>:</li></ul><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-yaml data-lang=yaml><span style=display:flex><span><span style=color:#75715e># docker-compose.yml (excerpt for Postgres)</span>
</span></span><span style=display:flex><span><span style=color:#f92672>services</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>db</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>image</span>: <span style=color:#ae81ff>postgres:16</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>environment</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>POSTGRES_DB</span>: <span style=color:#ae81ff>synapse</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>POSTGRES_USER</span>: <span style=color:#ae81ff>synapse</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>POSTGRES_PASSWORD</span>: <span style=color:#ae81ff>&lt;strong-password&gt;</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>POSTGRES_INITDB_ARGS</span>: <span style=color:#e6db74>&#34;--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C&#34;</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>volumes</span>:
</span></span><span style=display:flex><span> - <span style=color:#ae81ff>./pgdata:/var/lib/postgresql/data</span>
</span></span></code></pre></div><blockquote><p>Requires wiping the volume and recreating the DB.</p></blockquote><ul><li><strong>Pragmatic (works quickly)</strong>: In Synapses DB config, set <code>allow_unsafe_locale: true</code>. This bypasses the check. Its fine for hobby use; for production, prefer the clean <code>C</code> cluster.</li></ul><h3 id=2-start-synapse-and-generate-config>2) Start Synapse and generate config<a hidden class=anchor aria-hidden=true href=#2-start-synapse-and-generate-config>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker compose up -d db synapse
</span></span><span style=display:flex><span><span style=color:#75715e># Logs</span>
</span></span><span style=display:flex><span>docker compose logs --tail<span style=color:#f92672>=</span><span style=color:#ae81ff>200</span> synapse
</span></span></code></pre></div><p>Ensure Synapse prints your <strong>server_name</strong> and <strong>public base URL</strong> and stays up.</p><h3 id=3-create-an-admin-user>3) Create an admin user<a hidden class=anchor aria-hidden=true href=#3-create-an-admin-user>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e># Exec into the running Synapse container:</span>
</span></span><span style=display:flex><span>docker compose exec synapse register_new_matrix_user <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -c /data/homeserver.yaml -u &lt;username&gt; -p &lt;password&gt; <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -a -k
</span></span></code></pre></div><blockquote><p>If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use <code>docker compose exec synapse …</code> against the running container.</p></blockquote><hr><h2 id=turn-coturn>TURN (coTURN)<a hidden class=anchor aria-hidden=true href=#turn-coturn>#</a></h2><h3 id=1-avoid-bad-inline-comments>1) Avoid bad inline comments<a hidden class=anchor aria-hidden=true href=#1-avoid-bad-inline-comments>#</a></h3><p>If you see errors like:</p><div class="goat svg-container"><svg font-family="Menlo,Lucida Console,monospace" viewBox="0 0 808 25"><g transform="translate(8,16)"><text text-anchor="middle" x="0" y="4" fill="currentcolor" style="font-size:1em">E</text><text text-anchor="middle" x="8" y="4" fill="currentcolor" style="font-size:1em">R</text><text text-anchor="middle" x="16" y="4" fill="currentcolor" style="font-size:1em">R</text><text text-anchor="middle" x="24" y="4" fill="currentcolor" style="font-size:1em">O</text><text text-anchor="middle" x="32" y="4" fill="currentcolor" style="font-size:1em">R</text><text text-anchor="middle" x="40" y="4" fill="currentcolor" style="font-size:1em">:</text><text text-anchor="middle" x="56" y="4" fill="currentcolor" style="font-size:1em">U</text><text text-anchor="middle" x="64" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="72" y="4" fill="currentcolor" style="font-size:1em">k</text><text text-anchor="middle" x="80" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="88" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="96" y="4" fill="currentcolor" style="font-size:1em">w</text><text text-anchor="middle" x="104" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="120" y="4" fill="currentcolor" style="font-size:1em">b</text><text text-anchor="middle" x="128" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="136" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="144" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="152" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="160" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="168" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="184" y="4" fill="currentcolor" style="font-size:1em">v</text><text text-anchor="middle" x="192" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="200" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="208" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="216" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="224" y="4" fill="currentcolor" style="font-size:1em">:</text><text text-anchor="middle" x="240" y="4" fill="currentcolor" style="font-size:1em">#</text><text text-anchor="middle" x="256" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="264" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="272" y="4" fill="currentcolor" style="font-size:1em">g</text><text text-anchor="middle" x="288" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="296" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="312" y="4" fill="currentcolor" style="font-size:1em">j</text><text text-anchor="middle" x="320" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="328" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="336" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="344" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="352" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="360" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="368" y="4" fill="currentcolor" style="font-size:1em">d</text><text text-anchor="middle" x="376" y="4" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="384" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="392" y="4" fill="currentcolor" style="font-size:1em">y</text><text text-anchor="middle" x="400" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="408" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="416" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="424" y="4" fill="currentcolor" style="font-size:1em">g</text><text text-anchor="middle" x="432" y="4" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="448" y="4" fill="currentcolor" style="font-size:1em">Y</text><text text-anchor="middle" x="456" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="464" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="480" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="488" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="496" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="512" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="520" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="528" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="544" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="552" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="560" y="4" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="568" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="576" y="4" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="584" y="4" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="592" y="4" fill="currentcolor" style="font-size:1em">,</text><text text-anchor="middle" x="608" y="4" fill="currentcolor" style="font-size:1em">y</text><text text-anchor="middle" x="616" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="624" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="632" y="4" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="640" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="648" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="656" y="4" fill="currentcolor" style="font-size:1em">,</text><text text-anchor="middle" x="672" y="4" fill="currentcolor" style="font-size:1em">1</text><text text-anchor="middle" x="680" y="4" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="688" y="4" fill="currentcolor" style="font-size:1em">0</text><text text-anchor="middle" x="696" y="4" fill="currentcolor" style="font-size:1em">,</text><text text-anchor="middle" x="712" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="720" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="728" y="4" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="736" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="744" y="4" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="752" y="4" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="760" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="768" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="776" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="784" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="792" y="4" fill="currentcolor" style="font-size:1em">.</text></g></svg></div><p>…it means a <code>#</code> comment is on the <strong>same line</strong> as a boolean directive. Move comments to their own lines.</p><h3 id=2-minimal-turnserverconf>2) Minimal <code>turnserver.conf</code><a hidden class=anchor aria-hidden=true href=#2-minimal-turnserverconf>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-ini data-lang=ini><span style=display:flex><span><span style=color:#a6e22e>listening-port</span><span style=color:#f92672>=</span><span style=color:#e6db74>3478</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>tls-listening-port</span><span style=color:#f92672>=</span><span style=color:#e6db74>5349</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>fingerprint</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>use-auth-secret</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>static-auth-secret</span><span style=color:#f92672>=</span><span style=color:#e6db74>&lt;shared-secret&gt; # also set in Synapse</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>realm</span><span style=color:#f92672>=</span><span style=color:#e6db74>example.com # used in creds generation</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>total-quota</span><span style=color:#f92672>=</span><span style=color:#e6db74>0</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>bps-capacity</span><span style=color:#f92672>=</span><span style=color:#e6db74>0</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>cli-password</span><span style=color:#f92672>=</span><span style=color:#e6db74>&lt;admin-pass&gt;</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>no-cli</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>cert</span><span style=color:#f92672>=</span><span style=color:#e6db74>/etc/letsencrypt/live/turn.example.com/fullchain.pem</span>
</span></span><span style=display:flex><span><span style=color:#a6e22e>pkey</span><span style=color:#f92672>=</span><span style=color:#e6db74>/etc/letsencrypt/live/turn.example.com/privkey.pem</span>
</span></span><span style=display:flex><span><span style=color:#75715e># If behind NAT:</span>
</span></span><span style=display:flex><span><span style=color:#75715e># external-ip=&lt;public-ip&gt;/&lt;internal-ip&gt;</span>
</span></span></code></pre></div><h3 id=3-wire-turn-into-synapse>3) Wire TURN into Synapse<a hidden class=anchor aria-hidden=true href=#3-wire-turn-into-synapse>#</a></h3><p>In <code>homeserver.yaml</code>:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-yaml data-lang=yaml><span style=display:flex><span><span style=color:#f92672>turn_uris</span>:
</span></span><span style=display:flex><span> - <span style=color:#e6db74>&#34;turn:turn.example.com?transport=udp&#34;</span>
</span></span><span style=display:flex><span> - <span style=color:#e6db74>&#34;turn:turn.example.com?transport=tcp&#34;</span>
</span></span><span style=display:flex><span> - <span style=color:#e6db74>&#34;turns:turn.example.com:5349?transport=tcp&#34;</span>
</span></span><span style=display:flex><span><span style=color:#f92672>turn_shared_secret</span>: <span style=color:#e6db74>&#34;&lt;shared-secret&gt;&#34;</span>
</span></span><span style=display:flex><span><span style=color:#f92672>turn_user_lifetime</span>: <span style=color:#e6db74>&#34;1d&#34;</span>
</span></span></code></pre></div><p>Verify the homeserver issues TURN creds:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>TOKEN<span style=color:#f92672>=</span><span style=color:#e6db74>&#39;&lt;your matrix access token&gt;&#39;</span>
</span></span><span style=display:flex><span>curl -s -H <span style=color:#e6db74>&#34;Authorization: Bearer </span>$TOKEN<span style=color:#e6db74>&#34;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
</span></span></code></pre></div><p>You should see <code>uris</code>, a time-limited <code>username</code> and <code>password</code>.</p><hr><h2 id=nginx-host-for-synapse-and-federation>Nginx (host) for Synapse and federation<a hidden class=anchor aria-hidden=true href=#nginx-host-for-synapse-and-federation>#</a></h2><p>Create <code>/etc/nginx/conf.d/matrix.conf</code>:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#75715e># Client traffic on 443
</span></span></span><span style=display:flex><span><span style=color:#75715e></span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>443</span> <span style=color:#e6db74>ssl</span>; <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>matrix.example.com</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate</span> <span style=color:#e6db74>/etc/letsencrypt/live/matrix.example.com/fullchain.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate_key</span> <span style=color:#e6db74>/etc/letsencrypt/live/matrix.example.com/privkey.pem</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Proxy client &amp; admin APIs to Synapse (container) on 8008
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>location</span> ~ <span style=color:#e6db74>^(/_matrix|/_synapse/client)</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://127.0.0.1:8008</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-For</span> $remote_addr;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>client_max_body_size</span> <span style=color:#e6db74>50M</span>;
</span></span><span style=display:flex><span> }
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Advertise homeserver base + RTC focus (MSC4143) via .well-known
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>location</span> = <span style=color:#e6db74>/.well-known/matrix/client</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>default_type</span> <span style=color:#e6db74>application/json</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>add_header</span> <span style=color:#e6db74>Access-Control-Allow-Origin</span> <span style=color:#e6db74>&#34;*&#34;</span> <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>return</span> <span style=color:#ae81ff>200</span> <span style=color:#e6db74>&#39;</span>{<span style=color:#f92672>&#34;m.homeserver&#34;:{&#34;base_url&#34;:&#34;https://matrix.example.com&#34;},&#34;org.matrix.msc4143.rtc_foci&#34;:[{&#34;type&#34;:&#34;livekit&#34;,&#34;livekit_service_url&#34;:&#34;https://rtc.example.com&#34;}]}&#39;</span>;
</span></span><span style=display:flex><span> }
</span></span><span style=display:flex><span>}
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># Federation on 8448
</span></span></span><span style=display:flex><span><span style=color:#75715e></span><span style=color:#f92672>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>8448</span> <span style=color:#e6db74>ssl</span> <span style=color:#e6db74>http2</span>; <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:8448</span> <span style=color:#e6db74>ssl</span> <span style=color:#e6db74>http2</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>matrix.example.com</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate</span> <span style=color:#e6db74>/etc/letsencrypt/live/matrix.example.com/fullchain.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate_key</span> <span style=color:#e6db74>/etc/letsencrypt/live/matrix.example.com/privkey.pem</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>location</span> <span style=color:#e6db74>/</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://127.0.0.1:8008</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-For</span> $remote_addr;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>client_max_body_size</span> <span style=color:#e6db74>50M</span>;
</span></span><span style=display:flex><span> }
</span></span><span style=display:flex><span>}
</span></span></code></pre></div><p>Reload and sanity check:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo nginx -t <span style=color:#f92672>&amp;&amp;</span> sudo systemctl reload nginx
</span></span><span style=display:flex><span>curl -s https://matrix.example.com/.well-known/matrix/client | jq
</span></span></code></pre></div><p>Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -s https://matrix.example.com/_matrix/client/versions | jq <span style=color:#e6db74>&#39;.unstable_features.&#34;org.matrix.msc4140&#34;&#39;</span>
</span></span><span style=display:flex><span><span style=color:#75715e># expect: true</span>
</span></span></code></pre></div><hr><h2 id=element-call--livekit>Element Call + LiveKit<a hidden class=anchor aria-hidden=true href=#element-call--livekit>#</a></h2><p>Ill run <strong>LiveKit</strong> and the small <strong>JWT helper</strong> (Elements <code>elementcall_jwt</code>) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.</p><h3 id=1-livekit-config-etclivekityaml-inside-container>1) LiveKit config (<code>/etc/livekit.yaml</code> inside container)<a hidden class=anchor aria-hidden=true href=#1-livekit-config-etclivekityaml-inside-container>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-yaml data-lang=yaml><span style=display:flex><span><span style=color:#f92672>port</span>: <span style=color:#ae81ff>7880</span>
</span></span><span style=display:flex><span><span style=color:#f92672>bind_addresses</span>: [<span style=color:#e6db74>&#34;0.0.0.0&#34;</span>]
</span></span><span style=display:flex><span><span style=color:#f92672>rtc</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>tcp_port</span>: <span style=color:#ae81ff>7881</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>port_range_start</span>: <span style=color:#ae81ff>50100</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>port_range_end</span>: <span style=color:#ae81ff>50200</span>
</span></span><span style=display:flex><span> <span style=color:#f92672>use_external_ip</span>: <span style=color:#66d9ef>true</span>
</span></span><span style=display:flex><span><span style=color:#f92672>logging</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>level</span>: <span style=color:#ae81ff>info</span>
</span></span><span style=display:flex><span><span style=color:#f92672>turn</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>enabled</span>: <span style=color:#66d9ef>false</span>
</span></span><span style=display:flex><span><span style=color:#f92672>keys</span>:
</span></span><span style=display:flex><span> <span style=color:#f92672>lk_prod_1</span>: <span style=color:#e6db74>&#34;REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________&#34;</span>
</span></span></code></pre></div><blockquote><p><strong>Important:</strong> the secret must be <strong>>= 32 chars</strong>. The default <code>devkey</code> will trigger <code>secret is too short</code> warnings and wont work with the JWT helper.</p></blockquote><h3 id=2-elementcall_jwt-env>2) elementcall_jwt env<a hidden class=anchor aria-hidden=true href=#2-elementcall_jwt-env>#</a></h3><p>Run it with:</p><ul><li><code>LIVEKIT_URL=wss://rtc.example.com</code> <em>(root WS URL, <strong>no</strong> <code>/livekit/sfu</code> path)</em></li><li><code>LIVEKIT_KEY=lk_prod_1</code></li><li><code>LIVEKIT_SECRET=&lt;the long secret above></code></li><li><code>LIVEKIT_JWT_PORT=8080</code> (internal HTTP port the proxy will hit)</li><li>Optionally: <code>LIVEKIT_FULL_ACCESS_HOMESERVERS=*</code> during setup</li></ul><p>Check logs on start; it prints the LIVEKIT_URL it will advertise.</p><h3 id=3-nginx-host-for-rtcexamplecom>3) Nginx (host) for <code>rtc.example.com</code><a hidden class=anchor aria-hidden=true href=#3-nginx-host-for-rtcexamplecom>#</a></h3><p>Create <code>/etc/nginx/conf.d/rtc.conf</code>:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>443</span> <span style=color:#e6db74>ssl</span>; <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>rtc.example.com</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate</span> <span style=color:#e6db74>/etc/letsencrypt/live/rtc.example.com/fullchain.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate_key</span> <span style=color:#e6db74>/etc/letsencrypt/live/rtc.example.com/privkey.pem</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>access_log</span> <span style=color:#e6db74>/var/log/nginx/rtc.access.log</span> <span style=color:#e6db74>combined</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>location</span> = <span style=color:#e6db74>/sfu/get</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_hide_header</span> <span style=color:#e6db74>Access-Control-Allow-Origin</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>add_header</span> <span style=color:#e6db74>Access-Control-Allow-Origin</span> $http_origin <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>add_header</span> <span style=color:#e6db74>Vary</span> <span style=color:#e6db74>&#34;Origin&#34;</span> <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>add_header</span> <span style=color:#e6db74>Access-Control-Allow-Methods</span> <span style=color:#e6db74>&#34;POST,</span> <span style=color:#e6db74>OPTIONS&#34;</span> <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>add_header</span> <span style=color:#e6db74>Access-Control-Allow-Headers</span> <span style=color:#e6db74>&#34;Accept,</span> <span style=color:#e6db74>Content-Type,</span> <span style=color:#e6db74>Content-Length,</span> <span style=color:#e6db74>Accept-Encoding,</span> <span style=color:#e6db74>X-CSRF-Token,</span> <span style=color:#e6db74>Authorization&#34;</span> <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>if</span> <span style=color:#e6db74>(</span>$request_method = <span style=color:#e6db74>OPTIONS)</span> { <span style=color:#f92672>return</span> <span style=color:#ae81ff>204</span>; }
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://127.0.0.1:8070/sfu/get</span>; <span style=color:#75715e># elementcall_jwt
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> }
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># 3b) LiveKit WS &amp; HTTP (catch-all)
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>location</span> <span style=color:#e6db74>/</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_http_version</span> <span style=color:#ae81ff>1</span><span style=color:#e6db74>.1</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Upgrade</span> $http_upgrade;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Connection</span> <span style=color:#e6db74>&#34;upgrade&#34;</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Sec-WebSocket-Protocol</span> $http_sec_websocket_protocol; <span style=color:#75715e># &#34;livekit&#34;
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Origin</span> $http_origin;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_buffering</span> <span style=color:#66d9ef>off</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_read_timeout</span> <span style=color:#e6db74>3600s</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://127.0.0.1:7880</span>; <span style=color:#75715e># livekit
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> }
</span></span><span style=display:flex><span>}
</span></span></code></pre></div><p>Reload and basic checks:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo nginx -t <span style=color:#f92672>&amp;&amp;</span> sudo systemctl reload nginx
</span></span><span style=display:flex><span><span style=color:#75715e># JWT preflight (should return a single ACAO header)</span>
</span></span><span style=display:flex><span>curl -si -X OPTIONS https://rtc.example.com/sfu/get <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Origin: https://app.element.io&#39;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Access-Control-Request-Method: POST&#39;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Access-Control-Request-Headers: authorization, content-type&#39;</span> | sed -n <span style=color:#e6db74>&#39;1,30p&#39;</span>
</span></span><span style=display:flex><span><span style=color:#75715e># Expected: one Access-Control-Allow-Origin and 200/204</span>
</span></span></code></pre></div><blockquote><p><strong>Why I did this:</strong> I initially had <strong>two</strong> <code>Access-Control-Allow-Origin</code> headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.</p></blockquote><hr><h2 id=the-waiting-for-media-debugging-story-how-i-found-it>The “waiting for media” debugging story (how I found it)<a hidden class=anchor aria-hidden=true href=#the-waiting-for-media-debugging-story-how-i-found-it>#</a></h2><p>Symptom: Element Call created rooms, but calls stayed on <strong>“waiting for media.”</strong></p><p>What worked:</p><ul><li><code>elementcall_jwt</code> could <strong>CreateRoom</strong> in LiveKit (seen in logs).</li><li>TURN creds endpoint returned time-limited credentials.</li></ul><p>What didnt appear:</p><ul><li>No <strong>HTTP 101</strong> lines in <code>rtc.access.log</code> → the <strong>browser never established a WebSocket</strong> to LiveKit.</li></ul><h3 id=step-1-prove-the-ws-vhost-works-even-without-auth>Step 1: prove the WS vhost works (even without auth)<a hidden class=anchor aria-hidden=true href=#step-1-prove-the-ws-vhost-works-even-without-auth>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -v --http1.1 <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Connection: Upgrade&#39;</span> -H <span style=color:#e6db74>&#39;Upgrade: websocket&#39;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==&#39;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>&#39;Sec-WebSocket-Version: 13&#39;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> https://rtc.example.com/rtc -o /dev/null
</span></span></code></pre></div><p>Result: I got a <code>401</code> (expected), but importantly I saw a log line in <code>rtc.access.log</code>. So <strong>Nginx WS proxying was fine</strong>.</p><h3 id=step-2-check-the-browser-console>Step 2: check the browser console<a hidden class=anchor aria-hidden=true href=#step-2-check-the-browser-console>#</a></h3><p>The smoking gun in DevTools:</p><div class="goat svg-container"><svg font-family="Menlo,Lucida Console,monospace" viewBox="0 0 672 41"><g transform="translate(8,16)"><text text-anchor="middle" x="0" y="4" fill="currentcolor" style="font-size:1em">A</text><text text-anchor="middle" x="0" y="20" fill="currentcolor" style="font-size:1em">F</text><text text-anchor="middle" x="8" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="8" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="16" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="16" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="24" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="24" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="32" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="32" y="20" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="40" y="4" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="48" y="4" fill="currentcolor" style="font-size:1em">-</text><text text-anchor="middle" x="48" y="20" fill="currentcolor" style="font-size:1em">A</text><text text-anchor="middle" x="56" y="4" fill="currentcolor" style="font-size:1em">C</text><text text-anchor="middle" x="56" y="20" fill="currentcolor" style="font-size:1em">P</text><text text-anchor="middle" x="64" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="64" y="20" fill="currentcolor" style="font-size:1em">I</text><text text-anchor="middle" x="72" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="80" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="80" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="88" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="88" y="20" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="96" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="96" y="20" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="104" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="104" y="20" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="112" y="4" fill="currentcolor" style="font-size:1em">-</text><text text-anchor="middle" x="112" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="120" y="4" fill="currentcolor" style="font-size:1em">A</text><text text-anchor="middle" x="120" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="128" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="136" y="4" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="136" y="20" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="144" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="144" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="152" y="4" fill="currentcolor" style="font-size:1em">w</text><text text-anchor="middle" x="152" y="20" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="160" y="4" fill="currentcolor" style="font-size:1em">-</text><text text-anchor="middle" x="160" y="20" fill="currentcolor" style="font-size:1em">d</text><text text-anchor="middle" x="168" y="4" fill="currentcolor" style="font-size:1em">O</text><text text-anchor="middle" x="176" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="176" y="20" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="184" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="184" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="192" y="4" fill="currentcolor" style="font-size:1em">g</text><text text-anchor="middle" x="192" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="200" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="200" y="20" fill="currentcolor" style="font-size:1em">p</text><text text-anchor="middle" x="208" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="208" y="20" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="216" y="20" fill="currentcolor" style="font-size:1em">:</text><text text-anchor="middle" x="224" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="224" y="20" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="232" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="232" y="20" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="240" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="240" y="20" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="248" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="248" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="256" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="256" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="264" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="264" y="20" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="272" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="280" y="4" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="280" y="20" fill="currentcolor" style="font-size:1em">x</text><text text-anchor="middle" x="288" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="288" y="20" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="296" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="296" y="20" fill="currentcolor" style="font-size:1em">m</text><text text-anchor="middle" x="304" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="304" y="20" fill="currentcolor" style="font-size:1em">p</text><text text-anchor="middle" x="312" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="312" y="20" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="320" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="320" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="328" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="328" y="20" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="336" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="344" y="4" fill="currentcolor" style="font-size:1em">m</text><text text-anchor="middle" x="344" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="352" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="352" y="20" fill="currentcolor" style="font-size:1em">m</text><text text-anchor="middle" x="360" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="360" y="20" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="368" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="368" y="20" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="376" y="20" fill="currentcolor" style="font-size:1em">f</text><text text-anchor="middle" x="384" y="4" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="384" y="20" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="392" y="4" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="392" y="20" fill="currentcolor" style="font-size:1em">/</text><text text-anchor="middle" x="400" y="4" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="400" y="20" fill="currentcolor" style="font-size:1em">g</text><text text-anchor="middle" x="408" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="408" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="416" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="424" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="432" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="432" y="20" fill="currentcolor" style="font-size:1em">d</text><text text-anchor="middle" x="440" y="4" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="440" y="20" fill="currentcolor" style="font-size:1em">u</text><text text-anchor="middle" x="448" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="456" y="4" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="464" y="4" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="464" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="472" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="472" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="480" y="4" fill="currentcolor" style="font-size:1em">g</text><text text-anchor="middle" x="488" y="4" fill="currentcolor" style="font-size:1em">i</text><text text-anchor="middle" x="488" y="20" fill="currentcolor" style="font-size:1em">a</text><text text-anchor="middle" x="496" y="4" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="496" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="504" y="4" fill="currentcolor" style="font-size:1em">.</text><text text-anchor="middle" x="504" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="512" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="520" y="20" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="528" y="20" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="544" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="552" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="560" y="20" fill="currentcolor" style="font-size:1em">n</text><text text-anchor="middle" x="568" y="20" fill="currentcolor" style="font-size:1em">t</text><text text-anchor="middle" x="576" y="20" fill="currentcolor" style="font-size:1em">r</text><text text-anchor="middle" x="584" y="20" fill="currentcolor" style="font-size:1em">o</text><text text-anchor="middle" x="592" y="20" fill="currentcolor" style="font-size:1em">l</text><text text-anchor="middle" x="608" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="616" y="20" fill="currentcolor" style="font-size:1em">h</text><text text-anchor="middle" x="624" y="20" fill="currentcolor" style="font-size:1em">e</text><text text-anchor="middle" x="632" y="20" fill="currentcolor" style="font-size:1em">c</text><text text-anchor="middle" x="640" y="20" fill="currentcolor" style="font-size:1em">k</text><text text-anchor="middle" x="648" y="20" fill="currentcolor" style="font-size:1em">s</text><text text-anchor="middle" x="656" y="20" fill="currentcolor" style="font-size:1em">.</text></g></svg></div><p>The browser refused the JWT call due to duplicated <strong>ACAO</strong> headers, so no token → no WebSocket connect.</p><p><strong>Fix:</strong> in Nginx I added:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#66d9ef>proxy_hide_header</span> <span style=color:#e6db74>Access-Control-Allow-Origin</span>;
</span></span><span style=display:flex><span><span style=color:#66d9ef>add_header</span> <span style=color:#e6db74>Access-Control-Allow-Origin</span> $http_origin <span style=color:#e6db74>always</span>;
</span></span><span style=display:flex><span><span style=color:#66d9ef>add_header</span> <span style=color:#e6db74>Vary</span> <span style=color:#e6db74>&#34;Origin&#34;</span> <span style=color:#e6db74>always</span>;
</span></span></code></pre></div><p>…and ensured no other <code>add_header</code> created duplicates. After that, <code>/sfu/get</code> succeeded and the WebSocket to <code>wss://rtc.example.com</code> immediately followed (I saw <strong>HTTP 101</strong> in the logs).</p><h3 id=step-3-confirm-livekit-side>Step 3: confirm LiveKit side<a hidden class=anchor aria-hidden=true href=#step-3-confirm-livekit-side>#</a></h3><p>Once the WS was up, LiveKit logs showed participants joining (not just <code>RoomService.CreateRoom</code>), and calls were established.</p><hr><h2 id=useful-verification-commands>Useful verification commands<a hidden class=anchor aria-hidden=true href=#useful-verification-commands>#</a></h2><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e># Synapse features (expect MSC4140 true)</span>
</span></span><span style=display:flex><span>curl -s https://matrix.example.com/_matrix/client/versions | jq <span style=color:#e6db74>&#39;.unstable_features.&#34;org.matrix.msc4140&#34;&#39;</span>
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># Well-known with RTC focus</span>
</span></span><span style=display:flex><span>curl -s https://matrix.example.com/.well-known/matrix/client | jq
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># TURN creds (with your access token)</span>
</span></span><span style=display:flex><span>curl -s -H <span style=color:#e6db74>&#34;Authorization: Bearer </span>$TOKEN<span style=color:#e6db74>&#34;</span> <span style=color:#ae81ff>\
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># JWT health</span>
</span></span><span style=display:flex><span>curl -si -X POST https://rtc.example.com/sfu/get
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># LiveKit simple HTTP probe</span>
</span></span><span style=display:flex><span>curl -si https://rtc.example.com | head
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#75715e># Nginx logs (look for 101 Switching Protocols when a call starts)</span>
</span></span><span style=display:flex><span>sudo tail -f /var/log/nginx/rtc.access.log | grep <span style=color:#e6db74>&#39; 101 &#39;</span>
</span></span></code></pre></div><hr><h2 id=common-pitfalls-i-hit-these-so-you-dont-have-to>Common pitfalls (I hit these so you dont have to)<a hidden class=anchor aria-hidden=true href=#common-pitfalls-i-hit-these-so-you-dont-have-to>#</a></h2><ul><li><strong>Host Nginx vs Docker Nginx:</strong> If you already run Nginx on the host, dont also bind 80/443/8448 in a Docker Nginx — youll get <code>bind() ... already in use</code> and restart loops. Use <strong>host</strong> Nginx to reverse proxy to containers.</li><li><strong>Nginx <code>http2</code> directive:</strong> Old Nginx may not support the <code>http2</code> directive on <code>listen</code>. Use <code>listen 443 ssl;</code> (and add <code>http2</code> if your version supports it).</li><li><strong>Certificate name mismatch:</strong> Make sure <code>rtc.example.com</code>s vhost uses a certificate <strong>for that exact hostname</strong> (initially I had the <code>matrix.*</code> cert on <code>rtc.*</code> and curl complained).</li><li><strong>Postgres collation:</strong> Either initialize the cluster with <code>C</code> or use <code>allow_unsafe_locale: true</code> in Synapse DB config to get running quickly.</li><li><strong>CORS duplication on <code>/sfu/get</code>:</strong> Only ONE <code>Access-Control-Allow-Origin</code> header. If the upstream adds it too, use <code>proxy_hide_header Access-Control-Allow-Origin;</code> on the Nginx location.</li><li><strong>Cloudflare:</strong> Use <strong>DNS-only</strong> for <code>rtc.*</code>. Proxies can interfere with WS and UDP paths.</li><li><strong>Firewall:</strong> Open the LiveKit UDP range and TURN ports on both v4 and v6.</li></ul><hr><h2 id=final-checklist-print-me>Final checklist (print me)<a hidden class=anchor aria-hidden=true href=#final-checklist-print-me>#</a></h2><ul><li><input disabled type=checkbox> <code>https://matrix.example.com/.well-known/matrix/client</code> returns <strong>both</strong> <code>m.homeserver.base_url</code> and <code>org.matrix.msc4143.rtc_foci</code> pointing to <code>https://rtc.example.com</code>.</li><li><input disabled type=checkbox> <code>/_matrix/client/versions</code> shows <code>"org.matrix.msc4140": true</code>.</li><li><input disabled type=checkbox> <code>/sfu/get</code> <strong>preflight</strong> returns <strong>one</strong> <code>Access-Control-Allow-Origin</code> and <strong>200/204</strong>.</li><li><input disabled type=checkbox> Starting a call creates <strong>HTTP 101</strong> entries to <code>wss://rtc.example.com</code> in <code>rtc.access.log</code>.</li><li><input disabled type=checkbox> LiveKit logs show <strong>participants joining</strong> (not just CreateRoom).</li><li><input disabled type=checkbox> <code>/voip/turnServer</code> returns time-limited TURN credentials.</li><li><input disabled type=checkbox> Cloudflare set to <strong>DNS-only</strong> for <code>rtc.*</code>. UFW allows 7881/tcp and your LiveKit UDP range.</li></ul><hr><h3 id=credits--tooling>Credits & tooling<a hidden class=anchor aria-hidden=true href=#credits--tooling>#</a></h3><ul><li>Matrix Synapse, coTURN, LiveKit, Element Call.</li><li><code>curl</code>, <code>jq</code>, <code>docker compose logs</code>, Nginx access logs. These are your best friends.</li><li>The debugging breakthrough was catching CORS errors in the browser console and looking for <strong>HTTP 101</strong> in Nginx logs.</li></ul><p>Happy calling! 🎉</p></div><footer class=post-footer><ul class=post-tags><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/>Matrix</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/>Synapse</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/>Element-Call</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/>Livekit</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/>Coturn</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/>Nginx</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/>Webrtc</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/>Self-Hosting</a></li><li><a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/>Debugging</a></li></ul><nav class=paginav><a class=prev href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/><span class=title>« Prev</span><br><span>Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt</span>
</a><a class=next href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/><span class=title>Next »</span><br><span>Hello World</span></a></nav></footer><div class=comments-switch style="display:flex;gap:.5rem;margin:.5rem 0 1rem"><button id=btn-isso type=button aria-pressed=true>Anonymous (Isso)</button>
<button id=btn-giscus type=button aria-pressed=false>GitHub (Giscus)</button>
<a href=https://github.com/AlipourIm/blog-comments/discussions/categories/comments target=_blank rel=noopener>Open on GitHub ↗</a></div><div id=panel-isso><section id=isso-thread></section><script src=/isso/js/embed.min.js data-isso=/isso data-isso-css=true data-isso-lang=en data-isso-max-comments-nested=5 data-isso-sorting=newest async></script><small class=isso-powered style="display:block;margin:.5rem 0;color:var(--secondary,#888)">Comments powered by <a href=https://isso-comments.de target=_blank rel=noopener>Isso</a></small></div><div id=panel-giscus style=display:none><section id=giscus-thread></section></div><script>(function(){var t=document.getElementById("btn-isso"),n=document.getElementById("btn-giscus"),o=document.getElementById("panel-isso"),i=document.getElementById("panel-giscus"),s=!1;function e(e){var a,r=e==="isso";o.style.display=r?"":"none",i.style.display=r?"none":"",t.setAttribute("aria-pressed",r),n.setAttribute("aria-pressed",!r),!r&&!s&&(a=document.createElement("script"),a.src="https://giscus.app/client.js",a.async=!0,a.crossOrigin="anonymous",a.setAttribute("data-repo","AlipourIm/blog-comments"),a.setAttribute("data-repo-id","R_kgDOQGARyA"),a.setAttribute("data-category","Comments"),a.setAttribute("data-category-id","DIC_kwDOQGARyM4Cw3x-"),a.setAttribute("data-mapping","pathname"),a.setAttribute("data-strict","0"),a.setAttribute("data-reactions-enabled","1"),a.setAttribute("data-emit-metadata","0"),a.setAttribute("data-input-position","bottom"),a.setAttribute("data-theme","preferred_color_scheme"),a.setAttribute("data-lang","en"),document.getElementById("giscus-thread").appendChild(a),s=!0)}t.addEventListener("click",function(){e("isso")}),n.addEventListener("click",function(){e("giscus")}),e("isso")})()</script></article></main><footer class=footer><span>&copy; 2025 <a href=http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/>AlipourIm journeys</a></span> ·
<span>Powered by
<a href=https://gohugo.io/ rel="noopener noreferrer" target=_blank>Hugo</a> &
<a href=https://github.com/adityatelange/hugo-PaperMod/ rel=noopener target=_blank>PaperMod</a></span></footer><a href=#top aria-label="go to top" title="Go to Top (Alt + G)" class=top-link id=top-link accesskey=g><svg viewBox="0 0 12 6" fill="currentcolor"><path d="M12 6H0l6-6z"/></svg>
</a><a href=/index.xml rel=alternate type=application/rss+xml title=RSS class=rss-link><svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>
<span>RSS</span>
</a><script>let menu=document.getElementById("menu");menu&&(menu.scrollLeft=localStorage.getItem("menu-scroll-position"),menu.onscroll=function(){localStorage.setItem("menu-scroll-position",menu.scrollLeft)}),document.querySelectorAll('a[href^="#"]').forEach(e=>{e.addEventListener("click",function(e){e.preventDefault();var t=this.getAttribute("href").substr(1);window.matchMedia("(prefers-reduced-motion: reduce)").matches?document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView():document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView({behavior:"smooth"}),t==="top"?history.replaceState(null,null," "):history.pushState(null,null,`#${t}`)})})</script><script>var mybutton=document.getElementById("top-link");window.onscroll=function(){document.body.scrollTop>800||document.documentElement.scrollTop>800?(mybutton.style.visibility="visible",mybutton.style.opacity="1"):(mybutton.style.visibility="hidden",mybutton.style.opacity="0")}</script><script>document.getElementById("theme-toggle").addEventListener("click",()=>{document.body.className.includes("dark")?(document.body.classList.remove("dark"),localStorage.setItem("pref-theme","light")):(document.body.classList.add("dark"),localStorage.setItem("pref-theme","dark"))})</script><script>document.querySelectorAll("pre > code").forEach(e=>{const n=e.parentNode.parentNode,t=document.createElement("button");t.classList.add("copy-code"),t.innerHTML="copy";function s(){t.innerHTML="copied!",setTimeout(()=>{t.innerHTML="copy"},2e3)}t.addEventListener("click",t=>{if("clipboard"in navigator){navigator.clipboard.writeText(e.textContent),s();return}const n=document.createRange();n.selectNodeContents(e);const o=window.getSelection();o.removeAllRanges(),o.addRange(n);try{document.execCommand("copy"),s()}catch{}o.removeRange(n)}),n.classList.contains("highlight")?n.appendChild(t):n.parentNode.firstChild==n||(e.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName=="TABLE"?e.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(t):e.parentNode.appendChild(t))})</script></body></html>