<?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>Matrix – Serverküche</title><link>https://serverkueche.de/en/tags/matrix/</link><description>Matrix – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Thu, 12 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/matrix/index.xml" rel="self" type="application/rss+xml"/><item><title>Matrix Part 2: Element Web, Federation and Day-to-Day Admin</title><link>https://serverkueche.de/en/tutorials/matrix-element-federation/</link><pubDate>Thu, 12 Nov 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/matrix-element-federation/</guid><description>Element Web next to Synapse on the same domain, device verification, encrypted rooms, invite tokens and the admin API for accounts, rooms and media.</description><content:encoded><![CDATA[<p>The server from part 1 runs, federates and has exactly one account. Only nobody can see it. Now the
interface joins in, and with it the questions that actually come up in daily use: how do friends get
in without registration standing wide open? Who cleans up the rooms?</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We put Element Web 1.12.33 next to Synapse 1.164.2, on the same domain; Traefik splits the
requests by path. Afterwards you have a finished chat interface in the browser, an end-to-end
encrypted room, a registration that only works with an invite token, and the admin moves for
accounts, rooms and media.</p>
<p>This is part 2 of the Matrix series. Part 1 built the homeserver, the delegation and federation;
this one is about everything that comes after.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Why one domain and not two
  </p>
  <div class="prose-kitchen text-sm">Two subdomains (<code>matrix.</code> for the server, <code>chat.</code> for Element) are the more common route and
perfectly fine. We deliberately do it on just one domain here because that is the harder case: as
soon as two services share a hostname, the order of the Traefik rules decides who gets a request,
and that is exactly where it goes wrong in practice. If you have two subdomains, skip step 1 and
leave out the <code>priority</code> lines in step 3.</div>
</div>
<h2 id="requirements">Requirements</h2>
<ul>
<li>A running Matrix server from
<a href="/en/tutorials/matrix-synapse-server/">Matrix: Your Own Chat Server with Synapse (Part 1)</a>,
including a green federation test</li>
<li>Debian 13 with Docker and a Traefik that fetches the certificates</li>
<li>The domain from part 1; the <code>server_name</code> stays unchanged</li>
<li>About 60 minutes, a good part of it for the first login</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/matrix-element-federation/" data-content-piece="VPS 2000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 256 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €26.92/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Synapse grows from a good 120 to around 280 MB with the first large federated room. Element itself needs nothing – under 5 MB.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12.5-iv-24m-eu?ref=44083#vps-2000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 2000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200"
     data-voucher-api="vps-2000" data-voucher-msg-empty="No code is available for this plan right now – use the €5.95 code." data-voucher-msg-limit="No more codes today – use the €5.95 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5.95 code."
     data-voucher-hint="Empty the cart first – the code adds the server itself.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Show code</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Single use, valid for VPS 2000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 2000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5.95 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-how-two-services-share-one-hostname">Step 1: How two services share one hostname</h3>
<p>Synapse lays claim to three path prefixes and nothing else:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/_matrix         # the client and federation API
</span></span><span class="line"><span class="cl">/_synapse        # the admin API
</span></span><span class="line"><span class="cl">/.well-known/matrix  # the delegation from part 1</span></span></code></pre></div>
</div>
<p>Everything else (the start page, JavaScript, images, fonts) belongs to Element. Traefik can tell
them apart if you tell it which rule is checked first. That is what <code>priority</code> is for: the
higher the number, the earlier the rule is evaluated.</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Rule                                              priority   Result
</span></span><span class="line"><span class="cl">Host(`YOUR_DOMAIN`) &amp;&amp; PathPrefix(`/_matrix`)        100      Synapse
</span></span><span class="line"><span class="cl">Host(`YOUR_DOMAIN`)                                   10      Element</span></span></code></pre></div>
</div>
<p>Without <code>priority</code>, Traefik sorts by rule length and the longer one wins. That happens to give the
right result here, but you cannot rely on it: it can change with every rule you add. Write the numbers
down.</p>
<h3 id="step-2-configure-element">Step 2: Configure Element</h3>
<p>Element Web is pure JavaScript in the browser; the entire configuration sits in a single
<code>config.json</code>. Put it next to the <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/matrix
</span></span><span class="line"><span class="cl">nano element-config.json</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">JSON</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;default_server_config&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;m.homeserver&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;base_url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://YOUR_DOMAIN&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;server_name&#34;</span><span class="p">:</span> <span class="s2">&#34;YOUR_DOMAIN&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;brand&#34;</span><span class="p">:</span> <span class="s2">&#34;Kitchen Chat&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_custom_urls&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_guests&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_3pid_login&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;default_country_code&#34;</span><span class="p">:</span> <span class="s2">&#34;DE&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;show_labs_settings&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;room_directory&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;servers&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;YOUR_DOMAIN&#34;</span><span class="p">]</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;setting_defaults&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;breadcrumbs&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;UIFeature.feedback&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;UIFeature.registration&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>The switches that matter:</p>
<ul>
<li><code>disable_custom_urls: true</code>: The sign-in mask shows your server fixed instead of an input
field for arbitrary homeservers. Without it people accidentally sign in to matrix.org and wonder
why their account does not exist.</li>
<li><code>disable_3pid_login</code> and <code>disable_guests</code>: No login via email/phone (that would need an
identity server) and no guest accounts.</li>
<li><code>UIFeature.registration: false</code>: The &ldquo;Create account&rdquo; link disappears. We handle registration
in step 7 via tokens, not via an open button.</li>
<li><code>show_labs_settings: false</code>: Element has a whole drawer of unfinished features. On a server
other people share, that drawer is better left shut.</li>
</ul>
<p>The file has to be <strong>readable</strong> for the container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">chmod <span class="m">0644</span> element-config.json</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>If you hardened the server after Lynis
  </p>
  <div class="prose-kitchen text-sm">A <code>umask 027</code> in <code>/etc/login.defs</code>, as suggested by the
<a href="/en/tutorials/lynis-security-audit/">security audit with Lynis</a>, creates new files with <code>0640</code>.
Element runs unprivileged inside the container and then cannot read the file; the container refuses
to start with <code>cp: can't open '/app/config.json': Permission denied</code>. The <code>chmod</code> is therefore
mandatory.</div>
</div>
<h3 id="step-3-extend-the-stack">Step 3: Extend the stack</h3>
<p>In the <code>compose.yaml</code> from part 1 we change Synapse&rsquo;s Traefik rule and attach a second service:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/matrix/compose.yaml (excerpt)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">synapse</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/element-hq/synapse:v1.164.2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># ... unchanged ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.rule=Host(`YOUR_DOMAIN`) &amp;&amp; (PathPrefix(`/_matrix`) || PathPrefix(`/_synapse`) || PathPrefix(`/.well-known/matrix`))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.priority=100</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.entrypoints=websecure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.tls.certresolver=le</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.services.synapse.loadbalancer.server.port=8008</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">element</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vectorim/element-web:v1.12.33</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">element</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./element-config.json:/app/config.json:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.element.rule=Host(`YOUR_DOMAIN`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.element.priority=10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.element.entrypoints=websecure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.element.tls.certresolver=le</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.services.element.loadbalancer.server.port=80</span></span></span></code></pre></div>
</div>
<p>Element sits only in the <code>proxy</code> network. It never talks to the database and not directly to
Synapse either: the visitor&rsquo;s browser makes the API calls, not the container. The internal network
from part 1 stays reserved for the database.</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME        STATUS
</span></span><span class="line"><span class="cl">element     Up 20 seconds (healthy)
</span></span><span class="line"><span class="cl">matrix-db   Up 20 seconds (healthy)
</span></span><span class="line"><span class="cl">synapse     Up 20 seconds (healthy)</span></span></code></pre></div>
</div>
<h3 id="step-4-counter-check-both-routes">Step 4: Counter-check both routes</h3>
<p>Before you open a browser, check from the outside that the split really works. First the root, where
Element has to answer:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/ <span class="p">|</span> grep -o <span class="s2">&#34;&lt;title&gt;[^&lt;]*&lt;/title&gt;&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;title&gt;Element&lt;/title&gt;</span></span></code></pre></div>
</div>
<p>Then the configuration Element loads at startup:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/config.json <span class="p">|</span> head -5</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{
</span></span><span class="line"><span class="cl">  &#34;default_server_config&#34;: {
</span></span><span class="line"><span class="cl">    &#34;m.homeserver&#34;: {
</span></span><span class="line"><span class="cl">      &#34;base_url&#34;: &#34;https://YOUR_DOMAIN&#34;,</span></span></code></pre></div>
</div>
<p>And finally that Synapse kept its three paths:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://YOUR_DOMAIN/_matrix/client/versions
</span></span><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/.well-known/matrix/server
</span></span><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/.well-known/matrix/client</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">200
</span></span><span class="line"><span class="cl">{&#34;m.server&#34;:&#34;YOUR_DOMAIN:443&#34;}
</span></span><span class="line"><span class="cl">{&#34;m.homeserver&#34;:{&#34;base_url&#34;:&#34;https://YOUR_DOMAIN/&#34;}}</span></span></code></pre></div>
</div>
<p>If one of the three returns HTML instead of JSON, Element intercepted the request. Then the
<code>priority</code> is wrong. If the first one returns JSON instead of HTML, it is the other way round.</p>
<h3 id="step-5-the-first-login-and-device-verification">Step 5: The first login and device verification</h3>
<p>Open <code>https://YOUR_DOMAIN/</code> in the browser. Because <code>disable_custom_urls</code> is set, your server is
fixed in the mask; you only enter the username and password from part 1.</p>
<p><figure class="my-6"><img src="/en/tutorials/matrix-element-federation/el-login_hu_e23c2ccdb9633fb0.webp" srcset="/en/tutorials/matrix-element-federation/el-login_hu_e2ec2da6bfcbde02.webp 480w, /en/tutorials/matrix-element-federation/el-login_hu_e23c2ccdb9633fb0.webp 768w, /en/tutorials/matrix-element-federation/el-login_hu_2cd1641e8233c4c0.webp 1200w, /en/tutorials/matrix-element-federation/el-login_hu_3eea9ea60cd43d7a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/matrix-element-federation/el-login_hu_ea516308e3fd4b4e.webp"
    alt="The Element Web sign-in mask with a fixed homeserver and the fields for username and password" title="Element only asks for username and password – the homeserver is fixed"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Element only asks for username and password – the homeserver is fixed</figcaption></figure></p>
<p>On the very first device Element sets up encryption by itself, without asking: in the background
it creates a cross-signing key that marks your devices as yours, and a key backup on the
server. The server only stores encrypted copies of those; it cannot read them. All you see of it
is a prompt in the top left:</p>
<p><figure class="my-6"><img src="/en/tutorials/matrix-element-federation/el-backup-prompt_hu_9c86d22b48386748.webp" srcset="/en/tutorials/matrix-element-federation/el-backup-prompt_hu_a01370c5b2f88b5c.webp 480w, /en/tutorials/matrix-element-federation/el-backup-prompt_hu_9c86d22b48386748.webp 768w, /en/tutorials/matrix-element-federation/el-backup-prompt_hu_4eb6a89021b0a234.webp 1200w, /en/tutorials/matrix-element-federation/el-backup-prompt_hu_f26383e9fbb898b.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/matrix-element-federation/el-backup-prompt_hu_370248c433d82c5a.webp"
    alt="Element after the first sign-in with the prompt Back up your chats and the buttons Dismiss and Continue" title="The prompt after the first login – carry on with Continue, not Dismiss"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The prompt after the first login – carry on with Continue, not Dismiss</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Do not dismiss the key backup prompt
  </p>
  <div class="prose-kitchen text-sm"><p>The prompt &ldquo;Back up your chats&rdquo; says: &ldquo;Your chats are backed up automatically. You&rsquo;ll need your
recovery key to restore them if you lose all your devices.&rdquo; So the backup is already running,
but without a recovery key you will never get at it again.</p>
<p>Take <strong>Continue</strong>. Element opens the settings under &ldquo;Encryption&rdquo;; there &ldquo;Get recovery key&rdquo; and
&ldquo;Continue&rdquo; lead to &ldquo;Save your recovery key somewhere safe&rdquo; with the key. It belongs in your
password manager. Click &ldquo;Dismiss&rdquo; instead and you have none.</p>
<p>That only surfaces when you sign in on another device. Element then asks with &ldquo;Confirm your
digital identity&rdquo;: &ldquo;Use another device&rdquo; or &ldquo;Use recovery key&rdquo;. Without a key the second option is
greyed out, and once all old devices are gone only &ldquo;Can&rsquo;t confirm?&rdquo; remains, which resets your
identity, and the history that lives only on the server can no longer be deciphered. Not even by
you as the server administrator: a <code>pg_dump</code> saves the messages, not the keys for them.</p>
</div>
</div>
<h3 id="step-6-the-first-encrypted-room">Step 6: The first encrypted room</h3>
<p>Via &ldquo;New room&rdquo; you create a room. Two settings decide things here that are fixed later on:</p>
<p><figure class="my-6"><img src="/en/tutorials/matrix-element-federation/el-new-room_hu_69a8004cecb659af.webp" srcset="/en/tutorials/matrix-element-federation/el-new-room_hu_f8cc46de402fccd4.webp 480w, /en/tutorials/matrix-element-federation/el-new-room_hu_69a8004cecb659af.webp 768w, /en/tutorials/matrix-element-federation/el-new-room_hu_43ee10a008fbc61.webp 1200w, /en/tutorials/matrix-element-federation/el-new-room_hu_e07cc5738af5906a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/matrix-element-federation/el-new-room_hu_8f156acb1aefad22.webp"
    alt="The dialog Create a private room with a filled-in name, the selection Invite only and end-to-end encryption enabled" title="Invite only can be changed later, encryption cannot"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Invite only can be changed later, encryption cannot</figcaption></figure></p>
<ul>
<li>&ldquo;Invite only&rdquo;: The visibility can be changed later.</li>
<li>&ldquo;Enable end-to-end encryption&rdquo;: That one <strong>cannot</strong> be taken back. Element says so as
well: &ldquo;You can&rsquo;t disable this later. Bridges &amp; most bots won&rsquo;t work yet.&rdquo; An encrypted room is unusable for most bridges to other
networks and for many bots. If you are planning a Telegram or IRC bridge later, create an
unencrypted room for it.</li>
</ul>
<p>After that the room is there, and the first message goes out:</p>
<p><figure class="my-6"><img src="/en/tutorials/matrix-element-federation/el-room_hu_831210f44e4c2d2b.webp" srcset="/en/tutorials/matrix-element-federation/el-room_hu_aeb7e98ec796f94a.webp 480w, /en/tutorials/matrix-element-federation/el-room_hu_831210f44e4c2d2b.webp 768w, /en/tutorials/matrix-element-federation/el-room_hu_264ef3d18833b076.webp 1200w, /en/tutorials/matrix-element-federation/el-room_hu_408f8faee3cb4875.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/matrix-element-federation/el-room_hu_d0c48947b08dd8a6.webp"
    alt="An encrypted Matrix room in Element with the notice Encryption enabled and a first message in the timeline" title="The finished room: Encryption enabled sits as a system message at the start of the timeline"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The finished room: Encryption enabled sits as a system message at the start of the timeline</figcaption></figure></p>
<p>Through the admin API you can counter-check that the server really records the room as encrypted:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/rooms?limit=5&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">name: Cooking night | members: 1 | encryption: m.megolm.v1.aes-sha2</span></span></code></pre></div>
</div>
<p><code>m.megolm.v1.aes-sha2</code> is Matrix&rsquo;s group encryption scheme. If it says <code>null</code> there, the room is
readable in the clear, for the server and for anyone who gets hold of the database.</p>
<h3 id="step-7-let-friends-in-without-opening-registration">Step 7: Let friends in without opening registration</h3>
<p>Part 1 sets <code>enable_registration: false</code>. That is right as long as you are alone. Only you would
have to create every further account by hand and send the password over chat. The better way are
registration tokens: registration is open, but only with an invite code that you hand out.</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/matrix/data/homeserver.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">enable_registration</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">registration_requires_token</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose restart synapse</span></span></code></pre></div>
</div>
<p>Now you create a token, here one that may be used three times:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens/new&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{&#34;uses_allowed&#34;: 3, &#34;length&#34;: 12}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;token&#34;:&#34;LvM3cFXHXX7H&#34;,&#34;uses_allowed&#34;:3,&#34;pending&#34;:0,&#34;completed&#34;:0,&#34;expiry_time&#34;:null}</span></span></code></pre></div>
</div>
<p>That string is what you send your people. Element asks for it during registration. The easiest way
to check that nothing works without a token is at the start of the registration flow:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST <span class="s2">&#34;https://YOUR_DOMAIN/_matrix/client/v3/register&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> -d <span class="s1">&#39;{}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;session&#34;:&#34;PklWEdRKlnhHbBVJrwJnwnBG&#34;,
</span></span><span class="line"><span class="cl"> &#34;flows&#34;:[{&#34;stages&#34;:[&#34;m.login.registration_token&#34;,&#34;m.login.dummy&#34;]}],&#34;params&#34;:{}}</span></span></code></pre></div>
</div>
<p>The server now demands two stages, and the first one is the token. Without it (or with a wrong one, in
which case the server reports &ldquo;Invalid registration token&rdquo;) the flow ends without an account
being created. After a successful registration you can see the usage:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;registration_tokens&#34;:[{&#34;token&#34;:&#34;LvM3cFXHXX7H&#34;,&#34;uses_allowed&#34;:3,&#34;pending&#34;:0,&#34;completed&#34;:1,&#34;expiry_time&#34;:null}]}</span></span></code></pre></div>
</div>
<p><code>completed</code> are finished registrations, <code>pending</code> are started ones. Once the token is used up it
simply stops working; you do not have to switch anything off. With <code>&quot;expiry_time&quot;</code> (milliseconds
since 1970) you can additionally give it an expiry date.</p>
<h3 id="step-8-federation-in-daily-use">Step 8: Federation in daily use</h3>
<p>The federation test from part 1 checks whether others can reach your server. The other direction,
whether your server gets out, you check by asking about someone who only exists elsewhere:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s <span class="s2">&#34;https://YOUR_DOMAIN/_matrix/client/v3/profile/@matthew:matrix.org&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;displayname&#34;:&#34;Matthew&#34;,&#34;avatar_url&#34;:&#34;mxc://matrix.org/oUxxDyzQOHdVDMxgwFzyCWEe&#34;}</span></span></code></pre></div>
</div>
<p>Your server cannot have made that answer up: it asked matrix.org, checked the signature and passed
the result on. The same works with room addresses; the directory entry additionally tells you which
servers sit in the room.</p>
<p>What that costs shows when you join a real public room:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">time</span> curl -s -X POST <span class="s2">&#34;https://YOUR_DOMAIN/_matrix/client/v3/join/%23element-web:matrix.org&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> -H <span class="s2">&#34;Content-Type: application/json&#34;</span> -d <span class="s1">&#39;{}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;room_id&#34;:&#34;!xUW4PpAe1CmThA3r2wI8IrgwwsK006-zqWdJCljpd10&#34;}
</span></span><span class="line"><span class="cl">real    0m2.221s</span></span></code></pre></div>
</div>
<p>Two seconds. In the two minutes after that, while Synapse loads the room state (a good 5,000
state events), its memory climbs from around 120 MB to around 280 MB, the database from 15 to
63 MB. For a single room. That is the most important sentence about Matrix federation: your server
keeps the complete state of every room you sit in, including the part that comes from foreign
servers. A homeserver with 1 GB of RAM is therefore quickly finished once somebody joins three large
rooms.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Room IDs have changed their format
  </p>
  <div class="prose-kitchen text-sm">The room ID above has no colon and no server name any more. From room version 12 onwards, room IDs
are pure checksums without an origin. Old IDs of the form <code>!abcdef:matrix.org</code> stay valid. Scripts
that rely on the colon do not.</div>
</div>
<h3 id="step-9-day-to-day-admin">Step 9: Day-to-day admin</h3>
<p>There are three moves you need regularly. <strong>Retiring accounts.</strong> An account is never deleted but
deactivated, because the user ID lives on in foreign rooms:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/deactivate/@NAME:YOUR_DOMAIN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> -d <span class="s1">&#39;{&#34;erase&#34;: true}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;id_server_unbind_result&#34;:&#34;success&#34;}</span></span></code></pre></div>
</div>
<p><code>erase: true</code> additionally removes the display name and avatar. The user ID stays blocked and cannot
be handed out again. That is on purpose, otherwise someone could take over the identity.</p>
<p><strong>Getting rid of rooms</strong> you no longer need. First leave and forget, then purge from the server:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">ROOM</span><span class="o">=</span><span class="s1">&#39;!YOUR_ROOM_ID&#39;</span>
</span></span><span class="line"><span class="cl">curl -s -X DELETE <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v2/rooms/</span><span class="nv">$ROOM</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> -d <span class="s1">&#39;{&#34;purge&#34;: true, &#34;block&#34;: false}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;delete_id&#34;:&#34;qUcHmFcjfVZFfHWv&#34;}</span></span></code></pre></div>
</div>
<p>The call returns immediately, the cleanup runs in the background. You query the state at
<code>/_synapse/admin/v2/rooms/delete_status/DELETE_ID</code> until it says <code>&quot;status&quot;:&quot;complete&quot;</code>. With
<code>&quot;block&quot;: true</code> you additionally block the room so nobody can join it again.</p>
<p><strong>Cleaning up media.</strong> Every image from every federated room ends up as a copy on your disk:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">BEFORE</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span><span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> <span class="m">2592000</span><span class="o">)</span> <span class="o">*</span> <span class="m">1000</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">curl -s -X POST <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/media/YOUR_DOMAIN/delete?before_ts=</span><span class="nv">$BEFORE</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;deleted_media&#34;:[],&#34;total&#34;:0}</span></span></code></pre></div>
</div>
<p><code>before_ts</code> is a timestamp in <strong>milliseconds</strong>; the calculation above means &ldquo;older than 30 days&rdquo;.
Forget the three zeros and the call deletes nothing, because the point in time lands in 1970.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>The admin API does not belong on the open internet
  </p>
  <div class="prose-kitchen text-sm">Every call in this section hangs off a single token. Whoever has it can deactivate accounts and
delete rooms. Restrict <code>/_synapse/admin</code> in the reverse proxy to your VPN or your fixed address; the
pattern for that is in
<a href="/en/tutorials/internal-services-vpn-only/">Make internal services reachable only over VPN</a>.</div>
</div>
<h2 id="when-it-doesnt-work">When it doesn&rsquo;t work</h2>
<div class="troubleshoot not-prose">
<p><strong><code>/_matrix/client/versions</code> returns the Element start page instead of JSON.</strong> Both routers listen on the same host and Traefik checked the wrong one first. Set <code>traefik.http.routers.synapse.priority=100</code> and <code>…element.priority=10</code>. Without explicit numbers Traefik sorts by rule length, and that result changes as soon as another rule joins. After the change <code>docker compose up -d</code> is enough; Traefik re-reads the labels straight away.</p>
<p><strong>The Element container does not start and reports <code>cp: can't open '/app/config.json': Permission denied</code>.</strong> The <code>element-config.json</code> on the host is not readable for the container. This happens reliably on servers with <code>umask 027</code>, because new files then get <code>0640</code> and no read bit for others. <code>chmod 0644 /opt/matrix/element-config.json</code> and restart. The same applies to every configuration file you hand into a container as a bind mount.</p>
<p><strong>Element hangs at &ldquo;Confirm your digital identity&rdquo;, and &ldquo;Use recovery key&rdquo; is greyed out.</strong> You are signing in on another device and did not create a recovery key on the first one (step 5). If an old device is still signed in, take &ldquo;Use another device&rdquo; and confirm there. Otherwise only &ldquo;Can&rsquo;t confirm?&rdquo; followed by your account password remains: Element then resets the identity; account, contacts and rooms stay, but messages from before remain unreadable. Create the recovery key right afterwards.</p>
<p><strong>Signing in suddenly fails with <code>M_LIMIT_EXCEEDED</code>.</strong> The answer contains <code>retry_after_ms</code>, the waiting time in milliseconds. Synapse rate-limits login attempts per account and per IP; that hits you when you build several sessions in a row or test with scripts. For permanent operation it can be loosened through the <code>rc_login</code> block in <code>homeserver.yaml</code>, only you should not, because that exact brake is what stops guessing attacks.</p>
<p><strong>The device list and the logs show <code>172.x.x.x</code> instead of the real visitor IP.</strong> This only affects requests over <strong>IPv6</strong>, which, on a domain with an AAAA record, is most of them. The <code>proxy</code> network is created with <code>docker network create proxy</code> in the Traefik tutorial, and that is an IPv4-only network. Docker then cannot rewrite an incoming IPv6 connection onto the container directly and sends it through a helper process that replaces the source address with the bridge gateway&rsquo;s; over IPv4 the address is correct the whole time. The fix is a network with IPv6: <code>docker compose down</code> in every stack, then <code>docker network rm proxy &amp;&amp; docker network create --ipv6 --subnet fd00:cafe::/64 proxy</code> and bring everything back up; the steps are in <a href="/en/tutorials/traefik-reverse-proxy/">Reverse proxy with Traefik</a>. Afterwards the real address shows up in <code>X-Forwarded-For</code> over IPv4 <strong>and</strong> IPv6.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p>You update Element and Synapse separately. Element Web is a collection of static files with no
state of its own; an update is low-risk and takes seconds:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/matrix
</span></span><span class="line"><span class="cl">sed -i <span class="s1">&#39;s/element-web:v1.12.33/element-web:vNEW_VERSION/&#39;</span> compose.yaml
</span></span><span class="line"><span class="cl">docker compose pull element <span class="o">&amp;&amp;</span> docker compose up -d element
</span></span><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/version</span></span></code></pre></div>
</div>
<p>The last call prints the running version, for example <code>1.12.33</code>. Element follows Synapse loosely; a
version jump on the server does not break the interface. The reverse holds too, as long as you do
not skip several years.</p>
<p>What has to go into the backup on top of part 1:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/opt/matrix/element-config.json   # lives in no database</span></span></code></pre></div>
</div>
<p>It is a small file, but it holds every decision from step 2. If it is lost, Element starts with the
default configuration, and your people suddenly face an input field into which they can type any
homeserver at all.</p>
<p>Registration tokens need hygiene. A token with <code>uses_allowed: 3</code> and no expiry date is an invite
code that stays valid for years. Look at the list every few months and delete what you no longer
need:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X DELETE <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens/THE_TOKEN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<p>Memory use is the actual maintenance task. The jump from around 120 to 280 MB for a single
federated room scales onwards. Set yourself a reminder to look at <code>docker stats --no-stream</code> and
<code>du -sh /opt/matrix/data/media_store</code> every few months, and leave rooms where none of you writes any
more. That is more effective than any fine-tuning.</p>
<p>Realistically, Element itself creates no work: two or three updates a year. The time goes
into the homeserver: a Synapse update every two weeks with a look at the release notes, a media and
room cleanup once a quarter. Together roughly one hour a month.</p>
]]></content:encoded></item><item><title>Matrix: Your Own Chat Server with Synapse (Part 1)</title><link>https://serverkueche.de/en/tutorials/matrix-synapse-server/</link><pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/matrix-synapse-server/</guid><description>Synapse 1.164 with Postgres 18 behind Traefik: server name, delegation, closed registration – and the federation test that has to come back green.</description><content:encoded><![CDATA[<p>Your own chat server that can talk to the rest of the Matrix world: your messages live on your
server, and you still write to people on matrix.org or on their own server. The price is a couple of
decisions you make at the beginning and can hardly change later.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We run Synapse 1.164.2 with PostgreSQL 18.6 behind Traefik. At the end you have a Matrix
homeserver with registration closed, one account of your own and working federation. The official
federation tester confirms that it works.</p>
<p>This tutorial is part 1 and deliberately covers only the server. Element Web as the interface,
daily life with rooms, and administration come in part 2, otherwise this would turn into a book.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>One decision that sticks
  </p>
  <div class="prose-kitchen text-sm">The <code>server_name</code> becomes part of every user ID (<code>@you:YOUR_DOMAIN</code>) and every room address
(<code>#cooking-night:YOUR_DOMAIN</code>). Changing it afterwards is practically impossible: every account
would be a different one and federation would lose every mapping. Think about which domain you want permanently before the first start. Ideally short
and pleasant, because it will sit behind every name later.</div>
</div>
<h2 id="requirements">Requirements</h2>
<ul>
<li><a href="/en/tutorials/traefik-reverse-proxy/">Traefik as a reverse proxy</a> with a working Let&rsquo;s Encrypt
resolver</li>
<li>A <a href="/en/tutorials/connect-domain-to-server/">domain pointing at the server</a> (an A record and
ideally an AAAA record)</li>
<li>At least 2 GB of memory. Freshly started and idle, Synapse needs a good 100 MB and
Postgres around 60 MB; as soon as you join rooms with many participants, the appetite grows
noticeably</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/matrix-synapse-server/" data-content-piece="VPS 2000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 256 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €26.92/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Matrix grows with the rooms you join – the larger VPS is not a waste here.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12.5-iv-24m-eu?ref=44083#vps-2000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 2000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200"
     data-voucher-api="vps-2000" data-voucher-msg-empty="No code is available for this plan right now – use the €5.95 code." data-voucher-msg-limit="No more codes today – use the €5.95 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5.95 code."
     data-voucher-hint="Empty the cart first – the code adds the server itself.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Show code</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Single use, valid for VPS 2000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 2000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5.95 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-telling-server-name-host-and-delegation-apart">Step 1: Telling server name, host and delegation apart</h3>
<p>Matrix knows two different addresses, and that is the most common confusion at the start:</p>
<ul>
<li>The <code>server_name</code> appears in the IDs: <code>@cook:YOUR_DOMAIN</code>. It is pure naming.</li>
<li>The host Synapse actually runs on can be an entirely different one, <code>matrix.YOUR_DOMAIN</code>,
say.</li>
</ul>
<p>So that foreign servers find the second when they only know the first, there is <strong>delegation</strong>: at
<code>https://YOUR_DOMAIN/.well-known/matrix/server</code> sits a small JSON file pointing at the real host. In
the same way a chat client finds out where to connect via
<code>https://YOUR_DOMAIN/.well-known/matrix/client</code>.</p>
<p>In this guide <code>server_name</code> and host are the same domain. That is the simplest case, and Synapse can
then serve both files itself, so you need no additional web server. If you later move to
<code>matrix.YOUR_DOMAIN</code>, the <code>server_name</code> stays the same and only the delegation file changes.</p>
<h3 id="step-2-let-synapse-generate-the-configuration">Step 2: Let Synapse generate the configuration</h3>
<p>Synapse writes its own base configuration, including the secrets and the signing key:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /opt/matrix/data <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/matrix
</span></span><span class="line"><span class="cl">docker run --rm -v /opt/matrix/data:/data <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">SYNAPSE_SERVER_NAME</span><span class="o">=</span>YOUR_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">SYNAPSE_REPORT_STATS</span><span class="o">=</span>no <span class="se">\
</span></span></span><span class="line"><span class="cl">  ghcr.io/element-hq/synapse:v1.164.2 generate</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Creating log config /data/YOUR_DOMAIN.log.config
</span></span><span class="line"><span class="cl">Setting ownership on /data to 991:991
</span></span><span class="line"><span class="cl">Generating config file /data/homeserver.yaml
</span></span><span class="line"><span class="cl">Generating signing key file /data/YOUR_DOMAIN.signing.key
</span></span><span class="line"><span class="cl">A config file has been generated in &#39;/data/homeserver.yaml&#39; for server name &#39;YOUR_DOMAIN&#39;. Please review this file and customise it to your needs.</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>The signing key is irreplaceable
  </p>
  <div class="prose-kitchen text-sm">The file <code>YOUR_DOMAIN.signing.key</code> is your server&rsquo;s identity towards every other Matrix server. Lose
it and the rest of the network treats you as an impostor and rejects your events. It belongs in your
backup from day one, not once there is data in the system. The same goes for
<code>registration_shared_secret</code>, <code>macaroon_secret_key</code> and <code>form_secret</code> from <code>homeserver.yaml</code>.</div>
</div>
<p>The generated file uses SQLite. For anything beyond a single-seat test that is too little; we switch
<code>homeserver.yaml</code> to PostgreSQL and add the settings that concern this server:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/matrix/data/homeserver.yaml (excerpt – leave the secrets untouched)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">listeners</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8008</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tls</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">http</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">x_forwarded</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">bind_addresses</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;0.0.0.0&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">names</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">client, federation]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">compress</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">database</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">psycopg2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">args</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="l">synapse</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">password</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">dbname</span><span class="p">:</span><span class="w"> </span><span class="l">synapse</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cp_min</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cp_max</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">public_baseurl</span><span class="p">:</span><span class="w"> </span><span class="l">https://YOUR_DOMAIN/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">serve_server_wellknown</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">enable_registration</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">suppress_key_server_warning</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">max_upload_size</span><span class="p">:</span><span class="w"> </span><span class="l">50M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">url_preview_enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div>
</div>
<p>Four lines deserve an explanation:</p>
<ul>
<li><code>x_forwarded: true</code>: Synapse reads the real visitor IP from the <code>X-Forwarded-For</code> header that
Traefik sets. Without it every log line shows the proxy&rsquo;s address. The generated file already has
the switch set; leave it in place when you rework the file.</li>
<li><code>public_baseurl</code>: Synapse builds the client delegation and all links in invitations from it.</li>
<li><code>serve_server_wellknown: true</code>: Synapse serves <code>/.well-known/matrix/server</code> itself. That is
why we need no second web server.</li>
<li><code>url_preview_enabled: false</code>: Link previews make your server fetch every URL somebody pastes
into a room. That is a nice feature and at the same time a way to abuse your server as a fetching
tool for other people&rsquo;s addresses. Leave it off to begin with.</li>
</ul>
<h3 id="step-3-postgresql-with-the-right-collation">Step 3: PostgreSQL with the right collation</h3>
<p>Synapse requires a database with collation <code>C</code>. The reason is unspectacular and the consequence
annoying: with a language-dependent collation such as <code>de_DE.utf8</code>, Postgres compares strings
differently, and Synapse therefore refuses to start at all.</p>
<p>In the Docker image you get there via <code>POSTGRES_INITDB_ARGS</code> – <strong>on the very first start</strong>, because
after that the collation is fixed:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_INITDB_ARGS</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;--encoding=UTF8 --lc-collate=C --lc-ctype=C&#34;</span></span></span></code></pre></div>
</div>
<p>You can check it any time:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> db psql -U synapse -d synapse <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c <span class="s2">&#34;SELECT datname, pg_encoding_to_char(encoding), datcollate FROM pg_database WHERE datname=&#39;synapse&#39;;&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"> datname | pg_encoding_to_char | datcollate
</span></span><span class="line"><span class="cl">---------+---------------------+------------
</span></span><span class="line"><span class="cl"> synapse | UTF8                | C</span></span></code></pre></div>
</div>
<h3 id="step-4-the-stack">Step 4: The stack</h3>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/matrix/compose.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18.6-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">matrix-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">synapse</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${POSTGRES_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">synapse</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_INITDB_ARGS</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;--encoding=UTF8 --lc-collate=C --lc-ctype=C&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">pgdata:/var/lib/postgresql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">matrix-internal]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U synapse&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">synapse</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/element-hq/synapse:v1.164.2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">synapse</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SYNAPSE_CONFIG_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/data/homeserver.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">TZ</span><span class="p">:</span><span class="w"> </span><span class="l">Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">matrix-internal, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.rule=Host(`YOUR_DOMAIN`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.entrypoints=websecure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.synapse.tls.certresolver=le</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.services.synapse.loadbalancer.server.port=8008</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pgdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">matrix-internal</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">internal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The database sits in a network with <code>internal: true</code> – unreachable from outside and from the <code>proxy</code>
network. The database password lives in a <code>.env</code> next to it and has to match the value in
<code>homeserver.yaml</code>.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Note
  </p>
  <div class="prose-kitchen text-sm">The database mount is on <code>/var/lib/postgresql</code>, not on <code>/var/lib/postgresql/data</code>. From
PostgreSQL 18 on, the container refuses to start with the old path, even with an empty volume.</div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME        STATUS
</span></span><span class="line"><span class="cl">matrix-db   Up 55 seconds (healthy)
</span></span><span class="line"><span class="cl">synapse     Up 45 seconds (healthy)</span></span></code></pre></div>
</div>
<p>On first start Synapse creates the schema and then runs background migrations. The logs show lines
such as <code>Running background update 'threads_backfill'</code> for minutes. That is normal and does
not affect operation.</p>
<h3 id="step-5-the-four-endpoints-that-have-to-be-right">Step 5: The four endpoints that have to be right</h3>
<p>Before you touch any chat client, check four addresses from the outside. First, whether the client
API answers at all:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/_matrix/client/versions</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;versions&#34;:[&#34;r0.0.1&#34;,&#34;r0.1.0&#34;, … ,&#34;v1.16&#34;,&#34;v1.17&#34;],&#34;unstable_features&#34;:{ … }}</span></span></code></pre></div>
</div>
<p>Then the two delegation files that Synapse serves itself thanks to <code>serve_server_wellknown</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/.well-known/matrix/server
</span></span><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/.well-known/matrix/client</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;m.server&#34;:&#34;YOUR_DOMAIN:443&#34;}
</span></span><span class="line"><span class="cl">{&#34;m.homeserver&#34;:{&#34;base_url&#34;:&#34;https://YOUR_DOMAIN/&#34;}}</span></span></code></pre></div>
</div>
<p>And finally the key endpoint that foreign servers use to verify your server&rsquo;s signatures:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/_matrix/key/v2/server</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;old_verify_keys&#34;:{},&#34;server_name&#34;:&#34;YOUR_DOMAIN&#34;,&#34;signatures&#34;:{&#34;YOUR_DOMAIN&#34;:{&#34;ed25519:a_udcw&#34;:&#34; … &#34;}}, … }</span></span></code></pre></div>
</div>
<p>Lastly the check that the door is shut:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/register -d <span class="s1">&#39;{}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;errcode&#34;:&#34;M_FORBIDDEN&#34;,&#34;error&#34;:&#34;Registration has been disabled&#34;}</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Open registration is not an option
  </p>
  <div class="prose-kitchen text-sm">A Matrix server with open registration is reliably abused for spam within days. Then your
<code>server_name</code> lands on other servers&rsquo; block lists, and getting off those again is hard.
<code>enable_registration: false</code> stays, and you create accounts by hand.</div>
</div>
<h3 id="step-6-create-the-first-account">Step 6: Create the first account</h3>
<p>Because registration is closed, you use the bundled tool. It authenticates via the
<code>registration_shared_secret</code> from the configuration, and <code>-a</code> makes the user an administrator:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> synapse register_new_matrix_user <span class="se">\
</span></span></span><span class="line"><span class="cl">  -u YOUR_USERNAME -p YOUR_PASSWORD -a <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c /data/homeserver.yaml http://localhost:8008</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Sending registration request...
</span></span><span class="line"><span class="cl">Success!</span></span></code></pre></div>
</div>
<h3 id="step-7-counter-test-through-the-client-api">Step 7: Counter-test through the client API</h3>
<p>Now the actual functional test, with no chat client and just <code>curl</code>. First log in:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/login <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{&#34;type&#34;:&#34;m.login.password&#34;,&#34;identifier&#34;:{&#34;type&#34;:&#34;m.id.user&#34;,&#34;user&#34;:&#34;YOUR_USERNAME&#34;},&#34;password&#34;:&#34;YOUR_PASSWORD&#34;}&#39;</span></span></span></code></pre></div>
</div>
<p>The response contains <code>user_id</code>, <code>device_id</code> and an <code>access_token</code>. The user ID shows that the
<code>server_name</code> is in place:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;user_id&#34;:&#34;@YOUR_USERNAME:YOUR_DOMAIN&#34;</span></span></code></pre></div>
</div>
<p>With the token you create a room, send a message into it and read it back again. That proves the
whole chain of client API, database and event store:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/createRoom <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{&#34;name&#34;:&#34;Cooking night&#34;,&#34;preset&#34;:&#34;private_chat&#34;}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;room_id&#34;:&#34;!68AeABE_Q59hjgJkh8wIl3zdGbp_n7k4a9uOf4yDrF0&#34;}</span></span></code></pre></div>
</div>
<p>The room ID carries no server name: Synapse creates new rooms in room version 12, whose IDs are
plain hashes. Part 2 explains what that means.</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -X PUT <span class="s2">&#34;https://YOUR_DOMAIN/_matrix/client/v3/rooms/YOUR_ROOM_ID/send/m.room.message/txn1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span> -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{&#34;msgtype&#34;:&#34;m.text&#34;,&#34;body&#34;:&#34;Onion tart at 19:15&#34;}&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;$WHceSNXyP34qT0CoQjq0E97dm7p7ZVLArH7mZdKftyA&#34;}</span></span></code></pre></div>
</div>
<h3 id="step-8-check-federation">Step 8: Check federation</h3>
<p>Everything so far concerned your server alone. Whether the rest of the Matrix world can talk to it
is answered by the official federation tester. It resolves your delegation, opens a connection,
checks the certificate and compares the server name against the signature:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s <span class="s2">&#34;https://federationtester.matrix.org/api/report?server_name=YOUR_DOMAIN&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> python3 -c <span class="s2">&#34;import sys,json; d=json.load(sys.stdin); print(&#39;FederationOK:&#39;, d[&#39;FederationOK&#39;])&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">FederationOK: True</span></span></code></pre></div>
</div>
<p>The checker lists both addresses (IPv4 and IPv6) separately; both have to show
<code>ValidCertificates: True</code> and <code>MatchingServerName: True</code>. That is exactly what you want to see before you hand anyone your Matrix
address. The same check exists as a web interface at
<a href="https://federationtester.matrix.org/">federationtester.matrix.org</a>; the API is handier for
repeating it occasionally later.</p>
<h3 id="step-9-walling-off-the-admin-api">Step 9: Walling off the admin API</h3>
<p>Synapse ships its own administration API under <code>/_synapse/admin/</code>. With your administrator account&rsquo;s
token you can, for instance, list every account:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s <span class="s2">&#34;https://YOUR_DOMAIN/_synapse/admin/v2/users?from=0&amp;limit=5&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer YOUR_TOKEN&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">total: 1
</span></span><span class="line"><span class="cl">   @YOUR_USERNAME:YOUR_DOMAIN | admin: True | deactivated: False</span></span></code></pre></div>
</div>
<p>Without a token you are turned away, as expected:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;errcode&#34;:&#34;M_MISSING_TOKEN&#34;,&#34;error&#34;:&#34;Missing access token&#34;}</span></span></code></pre></div>
</div>
<p>One endpoint, however, is different:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://YOUR_DOMAIN/_synapse/admin/v1/server_version</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;server_version&#34;:&#34;1.164.2&#34;}</span></span></code></pre></div>
</div>
<p>This endpoint answers <strong>without any authentication</strong>. Synapse does that on purpose, but it tells
everyone which version you run and therefore which known issues are interesting for you. If you do
not need the admin path from outside at all, the cleanest move is to block it at the reverse proxy:
an <code>IPAllowList</code> middleware on <code>/_synapse/admin</code> costs two lines and settles the matter. The pattern
for that is in
<a href="/en/tutorials/internal-services-vpn-only/">making services reachable only over VPN</a>.</p>
<h2 id="when-it-doesnt-work">When it doesn&rsquo;t work</h2>
<div class="troubleshoot not-prose">
<p><strong>Synapse refuses to start and reports <code>IncorrectDatabaseSetup: Database has incorrect collation</code>.</strong> The database was created with a language-dependent collation such as <code>de_DE.utf8</code>; Synapse expects <code>C</code>. That cannot be changed afterwards, because the collation is fixed when the database is created. Delete the volume and start again with <code>POSTGRES_INITDB_ARGS: &quot;--encoding=UTF8 --lc-collate=C --lc-ctype=C&quot;</code>. The error message offers <code>allow_unsafe_locale</code> as a way out; do not take it, because wrongly sorted indexes will bite you later during searches.</p>
<p><strong>The federation tester reports <code>FederationOK: false</code> even though the site loads in a browser.</strong> Then the delegation is usually wrong. Check <code>curl -s https://YOUR_DOMAIN/.well-known/matrix/server</code>. The answer has to contain the host including the port, something like <code>{&quot;m.server&quot;:&quot;YOUR_DOMAIN:443&quot;}</code>. If you get a 404 here, <code>serve_server_wellknown: true</code> is missing from the configuration or another service intercepts the path. Also common: the server name in the delegation points at a host whose certificate is issued for a different name.</p>
<p><strong>The logs show Traefik&rsquo;s IP for every access instead of the visitor&rsquo;s.</strong> <code>x_forwarded: true</code> is missing from the <code>listeners</code> block. Synapse only evaluates the forwarding headers when you explicitly allow it. Without the real source address you can neither trace abuse nor use fail2ban sensibly.</p>
<p><strong>Logging in fails with <code>M_FORBIDDEN</code> even though the password is right.</strong> Check that you really are logging in at the right server: the user ID is <code>@name:server_name</code>, not <code>@name:host</code>. If <code>server_name</code> and host differ and the client delegation is missing, your client ends up in the wrong place. <code>curl -s https://YOUR_DOMAIN/.well-known/matrix/client</code> has to return the homeserver&rsquo;s base URL.</p>
<p><strong>The server runs but memory gets tight.</strong> Synapse uses little while idle (a good 100 MB) but grows noticeably once you join large, heavily federated rooms, because it keeps track of their entire history. When it gets tight, the first remedy is to leave such rooms and then remove old events with the admin API&rsquo;s cleanup functions. More memory is the other answer; a homeserver that takes part in real rooms is not happy on 1 GB.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p>Updates come often: Synapse publishes a new version roughly every two weeks, and security
fixes are among them. That makes a fixed rhythm necessary:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/matrix
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T db pg_dump -U synapse synapse &gt; before-update-<span class="k">$(</span>date +%F<span class="k">)</span>.sql
</span></span><span class="line"><span class="cl">sed -i <span class="s1">&#39;s/synapse:v1.164.2/synapse:vNEW_VERSION/&#39;</span> compose.yaml
</span></span><span class="line"><span class="cl">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs synapse --tail <span class="m">20</span></span></span></code></pre></div>
</div>
<p>Read the release notes before every jump, especially the &ldquo;deprecations&rdquo; section: Synapse regularly
drops old configuration keys, and the server then refuses to start with a clear but unexpected
message. Database migrations run automatically at startup; on large instances the first start after
an update can therefore take several minutes.</p>
<p>Four things need backing up, and the first is the most important:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/opt/matrix/data/YOUR_DOMAIN.signing.key   # your server&#39;s identity
</span></span><span class="line"><span class="cl">/opt/matrix/data/homeserver.yaml           # contains the three secrets
</span></span><span class="line"><span class="cl">pg_dump output                              # rooms, messages, accounts
</span></span><span class="line"><span class="cl">/opt/matrix/data/media_store/               # images and files from the rooms</span></span></code></pre></div>
</div>
<p>Without the signing key you cannot restore the server. You could only set up a new one, and every
other server would treat it as a new, unknown server. All of it belongs encrypted in your
<a href="/en/tutorials/restic-backups/">Restic backup</a>.</p>
<p>The <code>media_store</code> grows unnoticed: every image from every federated room you join ends up as a
copy on your disk. Check every few months with <code>du -sh /opt/matrix/data/media_store</code>; Synapse ships
an admin API for cleaning up that deletes old media.</p>
<p>Realistically, setting it up costs an evening. After that it is two appointments a month:
one update including a look at the release notes, one for disk space and the federation test. Matrix
is not a service you set up and forget; the protocol moves too fast for that.</p>
]]></content:encoded></item></channel></rss>