<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Webrtc on AlipourIm journeys</title>
    <link>http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/</link>
    <description>Recent content in Webrtc on AlipourIm journeys</description>
    <generator>Hugo -- 0.146.0</generator>
    <language>en</language>
    <lastBuildDate>Tue, 26 Aug 2025 00:00:00 +0000</lastBuildDate>
    <atom:link href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Self-hosting Matrix &#43; Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)</title>
      <link>http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/</link>
      <pubDate>Tue, 26 Aug 2025 00:00:00 +0000</pubDate>
      <guid>http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/</guid>
      <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 &amp;#39;waiting for media&amp;#39; to solid calls.</description>
      <content:encoded><![CDATA[<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</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</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 &amp; 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</h2>
<h3 id="1-the-postgresql-collation-gotcha">1) The PostgreSQL collation gotcha</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>
<pre tabindex="0"><code>Database has incorrect collation of &#39;en_US.utf8&#39;. Should be &#39;C&#39;
</code></pre><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 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</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</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)</h2>
<h3 id="1-avoid-bad-inline-comments">1) Avoid bad inline comments</h3>
<p>If you see errors like:</p>
<pre tabindex="0"><code>ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.
</code></pre><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></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</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</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</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)</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>&gt;= 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</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&gt;</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></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)</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)</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</h3>
<p>The smoking gun in DevTools:</p>
<pre tabindex="0"><code>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.
</code></pre><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</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</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 don’t have to)</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)</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>&quot;org.matrix.msc4140&quot;: 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 &amp; tooling</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:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE-----

iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu&#43;0un0FiIfHKtFPcXd2HiVqzODu
YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
PIe&#43;zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
SVJbwubie1D9TPcXU3ynC&#43;ynNBcmVevFl7g/X7Ie8Pw0SP1dJF&#43;we5iVqUrZgPO2
AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5&#43;HjkoIaZDtSfFyt&#43;AwPdHDQ
m3sMXA7yZUvy&#43;pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq&#43;yxtCuXBF
fPWo5pL0rMtIfwRyaiiX9bwV&#43;WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
7yoN0vqhKSvZlAL1OQS&#43;4yRcXnguq7UY9UF&#43;drG0f0QpC3aht1QgrJ04gvDp2BOk
ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl&#43;ZMvwobQqH
Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu&#43;Qt4h9gZ2w6
Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
=yHZZ
-----END PGP SIGNATURE-----
</pre>
  </details><p><em>Verify locally:</em></p>
  <pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
gpg --verify matrix_setup.md.asc matrix_setup.md
  </pre>
</div>


]]></content:encoded>
    </item>
  </channel>
</rss>
