203 lines
No EOL
54 KiB
HTML
203 lines
No EOL
54 KiB
HTML
<!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="noindex, nofollow"><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=https://blog.alipour.eu/posts/matrix_setup/><link crossorigin=anonymous href=/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as=style><link rel=icon href=https://blog.alipour.eu/favicon.ico><link rel=icon type=image/png sizes=16x16 href=https://blog.alipour.eu/favicon-16x16.png><link rel=icon type=image/png sizes=32x32 href=https://blog.alipour.eu/favicon-32x32.png><link rel=apple-touch-icon href=https://blog.alipour.eu/apple-touch-icon.png><link rel=mask-icon href=https://blog.alipour.eu/safari-pinned-tab.svg><meta name=theme-color content="#2e2e33"><meta name=msapplication-TileColor content="#2e2e33"><link rel=alternate hreflang=en href=https://blog.alipour.eu/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><script>(function(){window.goatcounter={endpoint:"/count"};const e=document.createElement("script");e.async=!0,e.src="/js/count.js",document.head.appendChild(e)})()</script><script>(function(){const s="",t=new Intl.NumberFormat,o="visit",i="visits";function a(e){document.readyState==="complete"||document.readyState==="interactive"?setTimeout(e,0):document.addEventListener("DOMContentLoaded",e)}function r(){try{return window.goatcounter&&window.goatcounter.get_data?window.goatcounter.get_data().p:location.pathname}catch{return location.pathname}}async function e(e,t){try{const o=new URLSearchParams({t:String(Date.now())});t&&o.set("start",t);const a=`${s}/counter/${encodeURIComponent(e)}.json?${o}`,n=await fetch(a,{credentials:"omit"});if(n.status===404)return 0;if(!n.ok)throw 0;const r=await n.json(),i=Number(String(r.count).replace(/,/g,""));return Number.isFinite(i)?i:0}catch{return null}}const n=e=>e===null?"—":`${t.format(e)} ${e===1?o:i}`;a(async function(){const s=document.querySelector(".post-single");if(s){const t=s.querySelector(".post-header .post-meta, .post-header .entry-meta, .post-meta"),o=await e(r());if(t){let e=t.querySelector(".post-views");e||(e=document.createElement("span"),e.className="post-views",t.appendChild(e)),e.textContent=n(o??0)}}const i=document.querySelectorAll("article.post-entry");await Promise.all(Array.from(i).map(async t=>{const s=t.querySelector(".entry-footer, .post-meta"),o=t.querySelector("a.entry-link, h2 a, .entry-title a");if(!s||!o)return;try{const i=new URL(o.getAttribute("href"),location.origin);if(i.origin!==location.origin)return;let t=s.querySelector(".post-views");t||(t=document.createElement("span"),t.className="post-views",s.appendChild(t)),t.textContent=n(await e(i.pathname)??0)}catch{}}));const o=document.getElementById("gc_site_visitors_week");if(o){const n=await e("TOTAL","week");o.textContent=n===null?"—":t.format(n)}})})()</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=https://blog.alipour.eu/ 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=https://blog.alipour.eu/posts/ title=Posts><span>Posts</span></a></li><li><a href=https://blog.alipour.eu/phd_journey/ title=PhD_journey><span>PhD_journey</span></a></li><li><a href=https://blog.alipour.eu/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=https://blog.alipour.eu/>Home</a> » <a href=https://blog.alipour.eu/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> · 8 min · Iman Alipour
|
||
<span class=post-meta-item><a href=https://blog.alipour.eu/posts/matrix_setup/#isso-thread class=isso-comments-link>Comments</a></span></div></header><figure class=entry-cover><img loading=eager src=https://blog.alipour.eu/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’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 Let’s 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>50100–50200/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=mermaid>Database has incorrect collation of 'en_US.utf8'. Should be 'C'</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><strong-password></span>
|
||
</span></span><span style=display:flex><span> <span style=color:#f92672>POSTGRES_INITDB_ARGS</span>: <span style=color:#e6db74>"--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"</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 Synapse’s DB config, set <code>allow_unsafe_locale: true</code>. This bypasses the check. It’s 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 <username> -p <password> <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=mermaid>ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.</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><shared-secret> # 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><admin-pass></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=<public-ip>/<internal-ip></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>"turn:turn.example.com?transport=udp"</span>
|
||
</span></span><span style=display:flex><span> - <span style=color:#e6db74>"turn:turn.example.com?transport=tcp"</span>
|
||
</span></span><span style=display:flex><span> - <span style=color:#e6db74>"turns:turn.example.com:5349?transport=tcp"</span>
|
||
</span></span><span style=display:flex><span><span style=color:#f92672>turn_shared_secret</span>: <span style=color:#e6db74>"<shared-secret>"</span>
|
||
</span></span><span style=display:flex><span><span style=color:#f92672>turn_user_lifetime</span>: <span style=color:#e6db74>"1d"</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>'<your matrix access token>'</span>
|
||
</span></span><span style=display:flex><span>curl -s -H <span style=color:#e6db74>"Authorization: Bearer </span>$TOKEN<span style=color:#e6db74>"</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 & 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>"*"</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>'</span>{<span style=color:#f92672>"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'</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>&&</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>'.unstable_features."org.matrix.msc4140"'</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>I’ll run <strong>LiveKit</strong> and the small <strong>JWT helper</strong> (Element’s <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>"0.0.0.0"</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>"REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"</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 won’t 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=<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>"Origin"</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>"POST,</span> <span style=color:#e6db74>OPTIONS"</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>"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"</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 & 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>"upgrade"</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># "livekit"
|
||
</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>&&</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>'Origin: https://app.element.io'</span> <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>'Access-Control-Request-Method: POST'</span> <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>'Access-Control-Request-Headers: authorization, content-type'</span> | sed -n <span style=color:#e6db74>'1,30p'</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 didn’t 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>'Connection: Upgrade'</span> -H <span style=color:#e6db74>'Upgrade: websocket'</span> <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='</span> <span style=color:#ae81ff>\
|
||
</span></span></span><span style=display:flex><span><span style=color:#ae81ff></span> -H <span style=color:#e6db74>'Sec-WebSocket-Version: 13'</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=mermaid>Access-Control-Allow-Origin cannot contain more than one origin.
|
||
Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.</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>"Origin"</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>'.unstable_features."org.matrix.msc4140"'</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>"Authorization: Bearer </span>$TOKEN<span style=color:#e6db74>"</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>' 101 '</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 don’t 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, don’t also bind 80/443/8448 in a Docker Nginx — you’ll 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><hr><div class=signature-block style=margin-top:1rem><p><strong>Downloads:</strong>
|
||
<a href=/sources/posts/matrix_setup.md>Markdown</a> ·
|
||
<a href=/sources/posts/matrix_setup.md.asc>Signature (.asc)</a></p><details class=signature><summary>View OpenPGP signature</summary><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>-----BEGIN PGP SIGNATURE-----
|
||
|
||
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
|
||
jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
|
||
YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
|
||
PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
|
||
SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
|
||
AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
|
||
m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
|
||
fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
|
||
7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
|
||
ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
|
||
Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
|
||
Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
|
||
=yHZZ
|
||
-----END PGP SIGNATURE-----
|
||
</pre></details><p><em>Verify locally:</em></p><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md
|
||
curl -fSLO https://blog.alipour.eu/sources/posts/matrix_setup.md.asc
|
||
gpg --verify matrix_setup.md.asc matrix_setup.md
|
||
</pre></div></div><footer class=post-footer><ul class=post-tags><li><a href=https://blog.alipour.eu/tags/matrix/>Matrix</a></li><li><a href=https://blog.alipour.eu/tags/synapse/>Synapse</a></li><li><a href=https://blog.alipour.eu/tags/element-call/>Element-Call</a></li><li><a href=https://blog.alipour.eu/tags/livekit/>Livekit</a></li><li><a href=https://blog.alipour.eu/tags/coturn/>Coturn</a></li><li><a href=https://blog.alipour.eu/tags/nginx/>Nginx</a></li><li><a href=https://blog.alipour.eu/tags/webrtc/>Webrtc</a></li><li><a href=https://blog.alipour.eu/tags/self-hosting/>Self-Hosting</a></li><li><a href=https://blog.alipour.eu/tags/debugging/>Debugging</a></li></ul><nav class=paginav><a class=prev href=https://blog.alipour.eu/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=https://blog.alipour.eu/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-giscus style=display:none><section id=giscus-thread></section></div><div id=panel-isso><section id=isso-thread data-isso-id=/posts/matrix_setup/ data-title="Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)"><noscript>Javascript needs to be activated to view comments.</noscript></section><script src=/isso/js/embed.min.js data-isso=/isso/ data-isso-css=false 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><script>(function(){var i=document.getElementById("panel-isso"),a=document.getElementById("panel-giscus"),e=document.getElementById("btn-isso"),t=document.getElementById("btn-giscus"),s=!1;function r(){if(n=localStorage.getItem("pref-theme"),n==="dark")return!0;if(n==="light")return!1;var n,e=document.documentElement,t=document.body;return!!(e.classList&&e.classList.contains("dark")||t.classList&&t.classList.contains("dark")||e.getAttribute("data-theme")==="dark"||t.getAttribute("data-theme")==="dark"||e.classList&&e.classList.contains("theme-dark")||t.classList&&t.classList.contains("theme-dark"))||window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches}function o(){return r()?"dark_dimmed":"noborder_light"}function c(){if(s)return;var t,n,e=document.createElement("script");e.src="https://giscus.app/client.js",e.async=!0,e.crossOrigin="anonymous",e.setAttribute("data-repo","AlipourIm/blog-comments"),e.setAttribute("data-repo-id","R_kgDOQGARyA"),e.setAttribute("data-category","Comments"),e.setAttribute("data-category-id","DIC_kwDOQGARyM4Cw3x-"),e.setAttribute("data-mapping","pathname"),e.setAttribute("data-strict","0"),e.setAttribute("data-reactions-enabled","1"),e.setAttribute("data-emit-metadata","0"),e.setAttribute("data-input-position","bottom"),e.setAttribute("data-lang","en"),e.setAttribute("data-theme",o()),document.getElementById("giscus-thread").appendChild(e);function i(){var e=document.querySelector("iframe.giscus-frame");e&&e.contentWindow&&e.contentWindow.postMessage({giscus:{setConfig:{theme:o()}}},"https://giscus.app")}t=document.getElementById("theme-toggle"),t&&t.addEventListener("click",function(){setTimeout(i,0)}),n=new MutationObserver(function(){i()}),n.observe(document.documentElement,{attributes:!0,attributeFilter:["class","data-theme"]}),n.observe(document.body,{attributes:!0,attributeFilter:["class","data-theme"]}),s=!0}function n(n){var s=n==="isso";i.style.display=s?"block":"none",a.style.display=s?"none":"block",e&&e.setAttribute("aria-pressed",s?"true":"false"),t&&t.setAttribute("aria-pressed",s?"false":"true"),s||c()}e&&e.addEventListener("click",function(e){e.preventDefault(),n("isso")}),t&&t.addEventListener("click",function(e){e.preventDefault(),n("giscus")}),n("isso")})()</script></article></main><footer class=footer><span>© 2026 <a href=https://blog.alipour.eu/>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><div class=site-meta-row style=display:flex;align-items:center;gap:.75rem;flex-wrap:wrap><a href=/index.xml rel=alternate type=application/rss+xml title=RSS class=rss-link style=display:inline-flex;align-items:center;gap:.35rem><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><span aria-hidden=true>·</span>
|
||
<span>Visitors this week: <span id=gc_site_visitors_week>—</span></span></div><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> |