<?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>Tailscale – Serverküche</title><link>https://serverkueche.de/tags/tailscale/</link><description>Tailscale – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>de-DE</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Tue, 20 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/tags/tailscale/index.xml" rel="self" type="application/rss+xml"/><item><title>Headscale: das eigene Tailscale-Netz</title><link>https://serverkueche.de/tutorials/headscale-selbst-hosten/</link><pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/tutorials/headscale-selbst-hosten/</guid><description>Headscale 0.29.5 als eigener Control-Server hinter Traefik: Knoten verbinden, MagicDNS, eigener DERP-Relay, ACLs und Exit-Node – ohne fremde Cloud.</description><content:encoded><![CDATA[<p>WireGuard ist großartig, bis du das fünfte Gerät einträgst: Jeder Peer braucht jeden anderen
Schlüssel, jede neue IP muss überall nachgepflegt werden, und hinter zwei NATs kommt trotzdem keine
Verbindung zustande. Genau das löst Tailscale – und Headscale löst es, ohne dass deine Geräteliste
bei einem fremden Anbieter liegt.</p>
<h2 id="was-bauen-wir">Was bauen wir?</h2>
<p>Wir betreiben <strong>Headscale 0.29.5</strong> als eigenen Control-Server hinter Traefik. Daran hängen zwei
Knoten mit dem offiziellen <strong>Tailscale-Client 1.104.1</strong>, die sich über MagicDNS beim Namen ansprechen.
Der Relay-Server (DERP) läuft eingebettet im selben Container, eine ACL-Datei regelt, wer wen
erreichen darf, und zum Schluss dient der Server als Exit-Node.</p>
<p>Wichtig für das Verständnis: <strong>Headscale ist nur die Steuerebene.</strong> Es verteilt öffentliche
Schlüssel, IP-Adressen und Regeln. Der eigentliche Datenverkehr läuft danach als direkte
WireGuard-Verbindung zwischen den Geräten – am Server vorbei. Fällt Headscale aus, reden bestehende
Knoten weiter miteinander; nur neue Geräte und Regeländerungen brauchen es.</p>
<p>Der Unterschied zu einem <a href="/tutorials/wireguard-vpn-einrichten/">klassischen WireGuard-Setup</a> ist
damit: dort ein Stern mit dem Server in der Mitte und Handarbeit pro Peer, hier ein Mesh, in dem sich
Geräte selbst finden und auch hinter NAT direkt verbinden.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Ein Server mit <a href="/tutorials/reverse-proxy-traefik/">Traefik als Reverse Proxy</a> und einer Domain,
die darauf zeigt – Headscale <strong>muss</strong> über HTTPS erreichbar sein</li>
<li><a href="/tutorials/wireguard-vpn-einrichten/">WireGuard-Grundlagen</a>: Du solltest wissen, was ein Peer,
ein öffentlicher Schlüssel und ein AllowedIP ist</li>
<li>Vertrautheit mit <a href="/tutorials/docker-compose-grundlagen/">Docker Compose</a></li>
<li>Ein zweites Gerät (Laptop, zweiter Server, Handy), das dem Netz beitreten kann</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 · /tutorials/headscale-selbst-hosten/" data-content-piece="VPS 1000 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>🍳 Empfehlung</span>
    <span title="Mit * markierte Links sind Affiliate-Links.">Anzeige</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 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCore · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">ab 14,50 €/Monat</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Headscale selbst ist genügsam – ein VPS 1000 reicht für Steuerebene und Relay.</p>
    </div>
    <a href="https://www.netcup.com/de/server/vps/vps-1000-g12.5-24m-eu?ref=44083#vps-1000-g12.5-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 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">
  Zu 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-1000" data-voucher-msg-empty="Gerade ist für diesen Tarif kein Code frei – nimm den 5,95-€-Code." data-voucher-msg-limit="Für heute sind keine weiteren Codes drin – nimm den 5,95-€-Code oder versuch es morgen." data-voucher-msg-error="Der Code ließ sich gerade nicht abrufen – nimm den 5,95-€-Code."
     data-voucher-hint="Warenkorb vorher leeren – der Code legt den Server selbst hinein.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 Monat gratis</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Zum Kopieren klicken"
            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">Code anzeigen</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">anderer Code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Einmal einlösbar, gilt für VPS 1000 G12.5.
    <a href="https://www.netcup.com/de/checkout/warenkorb?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Im Warenkorb einlösen →</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 € Gutschein</span> für netcup-Neukunden:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Zum Kopieren klicken" 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">immer gültig · nicht für Domains und VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/netcup-gutschein/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Alle netcup-Gutscheine →</a></p>
</div></div>
</div>

<h2 id="schritt-für-schritt">Schritt für Schritt</h2>
<h3 id="schritt-1-die-konfiguration-schreiben">Schritt 1: Die Konfiguration schreiben</h3>
<p>Headscale wird über eine einzige YAML-Datei konfiguriert. Leg die Ordner an:</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/headscale/config /opt/headscale/data <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/headscale</span></span></code></pre></div>
</div>
<p>Die Konfiguration ist der Teil, an dem die meisten Setups scheitern – deshalb hier in Blöcken mit
Begründung. Ersetze <code>DEINE_DOMAIN</code> durch die Domain, unter der Headscale erreichbar sein soll, und
<code>DEINE_SERVER_IP</code> durch die öffentliche IPv4 deines Servers.</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/headscale/config/config.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">server_url</span><span class="p">:</span><span class="w"> </span><span class="l">https://DEINE_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">listen_addr</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="p">:</span><span class="m">8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metrics_listen_addr</span><span class="p">:</span><span class="w"> </span><span class="m">127.0.0.1</span><span class="p">:</span><span class="m">9090</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">grpc_listen_addr</span><span class="p">:</span><span class="w"> </span><span class="m">127.0.0.1</span><span class="p">:</span><span class="m">50443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">grpc_allow_insecure</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">noise</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">private_key_path</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/headscale/noise_private.key</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">trusted_proxies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="m">172.19.0.0</span><span class="l">/16</span></span></span></code></pre></div>
</div>
<p><code>server_url</code> ist die Adresse, die alle Clients später benutzen – sie steht in jedem
<code>tailscale up</code>-Aufruf und lässt sich nachträglich nur mit Schmerzen ändern. <code>listen_addr</code> bindet auf
alle Interfaces des Containers, damit Traefik drankommt; nach außen ist trotzdem nur Traefik
erreichbar.</p>
<p><code>noise.private_key_path</code> ist Pflicht: Ohne den Eintrag startet Headscale nicht und meldet
<code>headscale now requires a new noise.private_key_path field in the config file for the Tailscale v2 protocol</code>. Den Schlüssel selbst legt Headscale beim ersten Start an.</p>
<p><code>trusted_proxies</code> ist der Eintrag, den man gern vergisst: Ohne ihn ignoriert Headscale die
<code>X-Forwarded-For</code>-Header und protokolliert jede Anmeldung mit der IP von Traefik. Trag das Subnetz
deines Docker-Proxy-Netzes ein – <code>docker network inspect proxy --format '{{ range .IPAM.Config }}{{ .Subnet }}{{ end }}'</code>
zeigt es dir.</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>Achtung
  </p>
  <div class="prose-kitchen text-sm">Setz <code>trusted_proxies</code> <strong>nur</strong>, wenn wirklich ein Proxy davor steht. Ohne Proxy erlaubst du damit
jedem Client, seine eigene Quell-IP im Protokoll zu fälschen.</div>
</div>
<p>Weiter mit den Adressbereichen und dem Relay:</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="nt">prefixes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">v4</span><span class="p">:</span><span class="w"> </span><span class="m">100.64.0.0</span><span class="l">/10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">v6</span><span class="p">:</span><span class="w"> </span><span class="l">fd7a:115c:a1e0::/48</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">allocation</span><span class="p">:</span><span class="w"> </span><span class="l">sequential</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">derp</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">server</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">enabled</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">region_id</span><span class="p">:</span><span class="w"> </span><span class="m">999</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">region_code</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;kueche&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">region_name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Eigener DERP&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">verify_clients</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">stun_listen_addr</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.0.0.0:3478&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">private_key_path</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/headscale/derp_server_private.key</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">automatically_add_embedded_derp_region</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">ipv4</span><span class="p">:</span><span class="w"> </span><span class="l">DEINE_SERVER_IP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">urls</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">paths</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">auto_update_enabled</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">update_frequency</span><span class="p">:</span><span class="w"> </span><span class="l">24h</span></span></span></code></pre></div>
</div>
<p>Die Präfixe <strong>müssen</strong> innerhalb von <code>100.64.0.0/10</code> bzw. <code>fd7a:115c:a1e0::/48</code> liegen – der
Tailscale-Client hat diese Bereiche fest verdrahtet und bricht bei anderen Werten auf subtile Weise.</p>
<p><strong>DERP</strong> ist Tailscales Relay: Wenn zwei Geräte partout keine direkte Verbindung aufbauen können
(symmetrisches NAT, restriktive Firewalls), läuft der Verkehr verschlüsselt über einen Relay-Server.
Standardmäßig sind das die Server von Tailscale Inc. Mit <code>enabled: true</code> betreibst du deinen eigenen
und setzt <code>urls: []</code>, damit die fremde Liste gar nicht erst geladen wird – dann verlässt kein Byte
deines Netzes die eigene Infrastruktur. <code>verify_clients: false</code> ist nötig, weil die Prüfung einen
lokalen <code>tailscaled</code>-Socket voraussetzt, den es im Container nicht gibt.</p>
<p>Zum Schluss Datenbank, Regeln und DNS:</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="nt">disable_check_updates</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></span><span class="line"><span class="cl"><span class="nt">node</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">expiry</span><span class="p">:</span><span class="w"> </span><span class="m">0</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">type</span><span class="p">:</span><span class="w"> </span><span class="l">sqlite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sqlite</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/headscale/db.sqlite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">write_ahead_log</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></span><span class="line"><span class="cl"><span class="nt">log</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">level</span><span class="p">:</span><span class="w"> </span><span class="l">info</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">format</span><span class="p">:</span><span class="w"> </span><span class="l">text</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">policy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="l">file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/headscale/policy.json</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">dns</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">magic_dns</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">base_domain</span><span class="p">:</span><span class="w"> </span><span class="l">kueche.internal</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">override_local_dns</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">nameservers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">global</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">9.9.9.9</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">149.112.112.112</span></span></span></code></pre></div>
</div>
<p>Die Policy-Datei aus <code>policy.path</code> muss <strong>beim Start existieren</strong>, sonst bricht Headscale mit
<code>loading policy: open /etc/headscale/policy.json: no such file or directory</code> ab. Bis du in Schritt 7
echte Regeln schreibst, legst du <code>/opt/headscale/config/policy.json</code> mit diesem Inhalt an – er
erlaubt alles:</p>
<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;acls&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;accept&#34;</span><span class="p">,</span> <span class="nt">&#34;src&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;*&#34;</span><span class="p">],</span> <span class="nt">&#34;dst&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;*:*&#34;</span><span class="p">]</span> <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="p">}</span></span></span></code></pre></div>
</div>
<p>SQLite ist hier kein Kompromiss, sondern die empfohlene Wahl – die Headscale-Entwickler entwickeln
und testen gegen SQLite, Postgres wird nur aus Altlastgründen weiter unterstützt.</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>base_domain darf nicht zur server_url passen
  </p>
  <div class="prose-kitchen text-sm"><code>base_domain</code> ist die Zone für MagicDNS und muss eine <strong>andere</strong> Domain sein als die in <code>server_url</code>.
Sonst startet Headscale gar nicht erst und meldet:
<code>server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable</code>.
Nimm etwas rein internes wie <code>kueche.internal</code>.</div>
</div>
<h3 id="schritt-2-den-stack-starten">Schritt 2: Den Stack starten</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/headscale/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">headscale</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/juanfont/headscale:v0.29.5</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">headscale</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">command</span><span class="p">:</span><span class="w"> </span><span class="l">serve</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">./config:/etc/headscale</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./data:/var/lib/headscale</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;3478:3478/udp&#34;</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.headscale.rule=Host(`DEINE_DOMAIN`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.headscale.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.headscale.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.headscale.loadbalancer.server.port=8080</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">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>Der Image-Tag ist bewusst auf <code>v0.29.5</code> festgenagelt. Headscale ist vor 1.0 und ändert zwischen
Minor-Versionen durchaus Konfigurationsformate – <code>latest</code> ist hier eine schlechte Idee.</p>
<p>Der einzige direkt veröffentlichte Port ist <strong>UDP 3478</strong> für STUN. Das braucht der DERP-Server, um
Clients bei der NAT-Durchdringung zu helfen; HTTPS und der DERP-Kanal selbst laufen über Traefik.
Wenn du <a href="/tutorials/firewall-ufw-einrichten/">UFW</a> einsetzt, muss 3478/udp offen sein – und hast du
in der <a href="/tutorials/netcup-firewall-einrichten/">netcup-Firewall</a> eine Policy zugewiesen, braucht sie
dort ebenfalls eine eingehende Regel für UDP 3478. Fehlt sie, funktioniert das Netz trotzdem, aber
jede Verbindung läuft über den Relay statt direkt (siehe Schritt 6).</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 logs --tail <span class="m">20</span></span></span></code></pre></div>
</div>
<p>In den Logs solltest du das hier sehen:</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">INF no private key file at path, creating... path=/var/lib/headscale/noise_private.key
</span></span><span class="line"><span class="cl">INF Opening database database=sqlite3 path=/var/lib/headscale/db.sqlite
</span></span><span class="line"><span class="cl">INF no private key file at path, creating... path=/var/lib/headscale/derp_server_private.key
</span></span><span class="line"><span class="cl">INF starting headscale commit=0f7d1e536bed9f01dd7161f7637af19bb0908335 version=v0.29.5
</span></span><span class="line"><span class="cl">INF Clients with a lower minimum version will be rejected minimum_version=v1.80
</span></span><span class="line"><span class="cl">INF stun server started at [::]:3478
</span></span><span class="line"><span class="cl">WRN listening without TLS but ServerURL does not start with http://
</span></span><span class="line"><span class="cl">INF listening and serving HTTP on: 0.0.0.0:8080</span></span></code></pre></div>
</div>
<p>Die Warnung in der vorletzten Zeile ist <strong>korrekt und erwartet</strong>: Headscale spricht intern HTTP, das TLS macht
Traefik. Merk dir außerdem die Zeile darüber – Clients älter als <strong>1.80</strong> werden abgewiesen.</p>
<p>Prüf jetzt von außen, ob die Kette steht:</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 https://DEINE_DOMAIN/health</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;status&#34;:&#34;pass&#34;}</span></span></code></pre></div>
</div>
<p>Kommt hier ein Zertifikatsfehler oder ein 404, ist das ein Traefik-Problem und keins von Headscale –
weiter unten steht, wie du das auseinanderhältst.</p>
<h3 id="schritt-3-benutzer-und-anmeldeschlüssel">Schritt 3: Benutzer und Anmeldeschlüssel</h3>
<p>Headscale ordnet jeden Knoten einem Benutzer zu. Ein Benutzer reicht für ein Privatnetz:</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> headscale headscale users create kueche
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> headscale headscale users list</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">ID | Name | Username | Email | Created
</span></span><span class="line"><span class="cl">1  |      | kueche   |       | 2026-09-10 02:20:44</span></span></code></pre></div>
</div>
<p>Neue Geräte melden sich mit einem <strong>Pre-Auth-Key</strong> an. Der <code>--user</code> erwartet die <strong>ID</strong> aus der
Liste, nicht den Namen:</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> headscale headscale preauthkeys create --user <span class="m">1</span> --reusable --expiration 1h</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">hskey-auth-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX</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>Vorsicht
  </p>
  <div class="prose-kitchen text-sm">Dieser Schlüssel ist ein vollwertiger Netzzugang. Wer ihn hat, hängt ein eigenes Gerät in dein VPN.
Halte die Laufzeit kurz (<code>--expiration 1h</code>), verschick ihn nicht über Chat oder Mail, und lass
<code>--reusable</code> weg, wenn du nur ein einziges Gerät verbindest. Nach dem Anlegen bekommst du ihn <strong>nur
dieses eine Mal</strong> vollständig zu sehen – <code>preauthkeys list</code> zeigt später nur noch das Präfix.</div>
</div>
<h3 id="schritt-4-den-ersten-knoten-verbinden">Schritt 4: Den ersten Knoten verbinden</h3>
<p>Auf dem Gerät, das beitreten soll, installierst du den offiziellen Tailscale-Client. Der Schlüssel
kommt als binäre Datei, deshalb braucht es kein <code>gnupg</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 -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o /usr/share/keyrings/tailscale-archive-keyring.gpg
</span></span><span class="line"><span class="cl">curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o /etc/apt/sources.list.d/tailscale.list
</span></span><span class="line"><span class="cl">chmod <span class="m">0644</span> /usr/share/keyrings/tailscale-archive-keyring.gpg /etc/apt/sources.list.d/tailscale.list</span></span></code></pre></div>
</div>
<p>Das <code>chmod</code> sieht überflüssig aus, ist es aber nicht: Auf gehärteten Systemen mit <code>UMASK 027</code> legt
<code>curl</code> die Dateien mit <code>0640</code> an, und apt kann sie dann nicht mehr lesen. Details dazu unten bei
„Wenn es nicht funktioniert&quot;.</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">apt-get update
</span></span><span class="line"><span class="cl">apt-get install -y tailscale
</span></span><span class="line"><span class="cl">tailscale 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">1.104.1</span></span></code></pre></div>
</div>
<p>Jetzt der Beitritt. Der entscheidende Schalter ist <code>--login-server</code>, der den Client auf deinen
Headscale statt auf Tailscales Cloud zeigt:</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">tailscale up <span class="se">\
</span></span></span><span class="line"><span class="cl">  --login-server<span class="o">=</span>https://DEINE_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --authkey<span class="o">=</span>DEIN_PREAUTH_KEY <span class="se">\
</span></span></span><span class="line"><span class="cl">  --accept-dns<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --hostname<span class="o">=</span>kueche-server</span></span></code></pre></div>
</div>
<p><code>--accept-dns=false</code> ist auf <strong>Servern</strong> wichtig: Sonst schreibt <code>tailscaled</code> die <code>/etc/resolv.conf</code>
um und leitet die Namensauflösung über das VPN. Auf einem Server, der weiterlaufen muss, ist das
selten gewollt. Auf Laptops und Handys lässt du die Option weg – dort willst du MagicDNS ja gerade.</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">tailscale ip -4</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">100.64.0.1</span></span></code></pre></div>
</div>
<h3 id="schritt-5-zweiter-knoten-und-magicdns">Schritt 5: Zweiter Knoten und MagicDNS</h3>
<p>Der zweite Knoten wird genauso installiert, nur mit anderem Hostnamen und ohne
<code>--accept-dns=false</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">tailscale up <span class="se">\
</span></span></span><span class="line"><span class="cl">  --login-server<span class="o">=</span>https://DEINE_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --authkey<span class="o">=</span>DEIN_PREAUTH_KEY <span class="se">\
</span></span></span><span class="line"><span class="cl">  --hostname<span class="o">=</span>laptop</span></span></code></pre></div>
</div>
<p>Auf dem Server siehst du beide Knoten:</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> headscale headscale nodes list</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">ID | Hostname      | Name          | MachineKey | NodeKey | User   | Tags | IP addresses      | Ephemeral | Last seen           | Expiration | Connected | Expired
</span></span><span class="line"><span class="cl">1  | kueche-server | kueche-server | [k54+k]    | [71w+9] | kueche |      | 100.64.0.1        | false     | 2026-10-11 19:55:56 | N/A        | online    | no
</span></span><span class="line"><span class="cl">   |               |               |            |         |        |      | fd7a:115c:a1e0::1 |           |                     |            |           |
</span></span><span class="line"><span class="cl">2  | laptop        | laptop        | [QqjtX]    | [7F2yB] | kueche |      | 100.64.0.2        | false     | 2026-10-11 19:56:34 | N/A        | online    | no
</span></span><span class="line"><span class="cl">   |               |               |            |         |        |      | fd7a:115c:a1e0::2 |           |                     |            |           |</span></span></code></pre></div>
</div>
<p>Auf dem Laptop hat <code>tailscaled</code> inzwischen den DNS-Resolver umgebogen:</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">cat /etc/resolv.conf</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"># resolv.conf(5) file generated by tailscale
</span></span><span class="line"><span class="cl">nameserver 100.100.100.100
</span></span><span class="line"><span class="cl">nameserver fd7a:115c:a1e0::53
</span></span><span class="line"><span class="cl">search kueche.internal</span></span></code></pre></div>
</div>
<p><code>100.100.100.100</code> ist der eingebaute MagicDNS-Resolver. Damit klappt jetzt das, wofür man das Ganze
eigentlich macht:</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">ping -c <span class="m">2</span> kueche-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">2 packets transmitted, 2 received, 0% packet loss, time 1001ms
</span></span><span class="line"><span class="cl">rtt min/avg/max/mdev = 0.728/0.764/0.800/0.036 ms</span></span></code></pre></div>
</div>
<p>Der kurze Name reicht, <code>kueche-server.kueche.internal</code> funktioniert genauso. Ob die Verbindung
wirklich direkt läuft oder über den Relay geht, sagt dir <code>tailscale ping</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">tailscale ping kueche-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">pong from kueche-server (100.64.0.1) via 172.19.0.1:41641 in 0s</span></span></code></pre></div>
</div>
<p>Kommt keine direkte Verbindung zustande, arbeitet der Relay, und alles funktioniert weiter, nur mit
etwas mehr Latenz. Dann sieht es so aus:</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">pong from kueche-server (100.64.0.1) via DERP(kueche) in 1ms
</span></span><span class="line"><span class="cl">direct connection not established</span></span></code></pre></div>
</div>
<p>Der häufigste Grund auf einem netcup-Server ist eine Firewall-Policy im SCP, die eingehendes UDP
sperrt.</p>
<h3 id="schritt-6-prüfen-dass-der-eigene-relay-benutzt-wird">Schritt 6: Prüfen, dass der eigene Relay benutzt wird</h3>
<p>Der Sinn des eingebetteten DERP-Servers ist, dass im Ernstfall nicht Tailscales Infrastruktur
einspringt. Ob das greift, zeigt der Client:</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">tailscale netcheck</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">* Nearest DERP: Eigener DERP
</span></span><span class="line"><span class="cl">* DERP latency:
</span></span><span class="line"><span class="cl">    - kueche: 400µs   (Eigener DERP)</span></span></code></pre></div>
</div>
<p>Nur eine Region, und zwar deine. Steht weiter oben in der Ausgabe <code>UDP: false</code>, erreicht der Client
deinen STUN-Port nicht – der Fall aus Schritt 2, in dem die netcup-Firewall UDP 3478 nicht
durchlässt. Erscheint hier eine Liste mit <code>fra</code>, <code>ams</code> oder ähnlichen Codes,
hat der Client die öffentliche DERP-Liste geladen – dann fehlt <code>urls: []</code> in der Konfiguration.</p>
<h3 id="schritt-7-acls--wer-darf-mit-wem">Schritt 7: ACLs – wer darf mit wem</h3>
<p>Bisher erlaubt deine Policy-Datei aus Schritt 1 <strong>jedem Knoten alles</strong>. Sobald echte Regeln darin
stehen, gilt Default-Deny: Was nicht ausdrücklich erlaubt ist, wird verworfen. Eine minimale,
sinnvolle Regel – sie ersetzt den Inhalt von <code>/opt/headscale/config/policy.json</code>:</p>
<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;groups&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;group:haushalt&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;kueche@&#34;</span><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;acls&#34;</span><span class="p">:</span> <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;action&#34;</span><span class="p">:</span> <span class="s2">&#34;accept&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;src&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;group:haushalt&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;dst&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;group:haushalt:22,80,443&#34;</span><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="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;accept&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;src&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;group:haushalt&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;dst&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;autogroup:internet:*&#34;</span><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="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>Beachte das <code>@</code> hinter dem Benutzernamen – seit dem Policy-Format v2 ist es Pflicht. Prüf die Datei,
bevor du neu startest:</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> headscale headscale policy check --file /etc/headscale/policy.json</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">Policy is valid</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 headscale</span></span></code></pre></div>
</div>
<p>Und jetzt gegenprüfen, dass die Regel wirklich greift. Port 443 ist erlaubt, 9090 nicht. Damit die
Probe etwas beweist, muss auf 9090 aber auch etwas lauschen – sonst meldet sie „BLOCKIERT“ schon,
weil dort niemand antwortet. Starte auf dem Server kurz einen Testdienst:</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">python3 -m http.server <span class="m">9090</span> --bind 100.64.0.1</span></span></code></pre></div>
</div>
<p>Und prüf dann vom Laptop aus:</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">timeout <span class="m">5</span> bash -c <span class="s2">&#34;&lt;/dev/tcp/100.64.0.1/443&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> OFFEN <span class="o">||</span> <span class="nb">echo</span> BLOCKIERT
</span></span><span class="line"><span class="cl">timeout <span class="m">5</span> bash -c <span class="s2">&#34;&lt;/dev/tcp/100.64.0.1/9090&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> OFFEN <span class="o">||</span> <span class="nb">echo</span> BLOCKIERT</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">OFFEN
</span></span><span class="line"><span class="cl">BLOCKIERT</span></span></code></pre></div>
</div>
<p>Mit der Alles-erlauben-Datei aus Schritt 1 stünde in der zweiten Zeile <code>OFFEN</code> – so weißt du, dass
die Regel den Unterschied macht. Den Testdienst beendest du danach mit Strg+C.</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>ICMP folgt der Portregel nicht
  </p>
  <div class="prose-kitchen text-sm">Ein <code>ping</code> auf <code>100.64.0.1</code> klappt trotzdem, obwohl in der Regel nur 22, 80 und 443 stehen. Das ist
kein Fehler: Der Tailscale-Paketfilter lässt ICMP zu, sobald die Quelle <strong>irgendeinen</strong> Port am Ziel
erreichen darf. Nimm die Gegenprobe – entfernst du das Ziel komplett aus den Regeln, ist auch der
Ping weg (<code>2 packets transmitted, 0 received</code>). Ein erfolgreicher Ping beweist also nur, dass
überhaupt eine Regel greift, nicht welche.</div>
</div>
<h3 id="schritt-8-der-server-als-exit-node">Schritt 8: Der Server als Exit-Node</h3>
<p>Ein Exit-Node schickt den kompletten Internetverkehr eines Geräts über den Server – praktisch im
fremden WLAN. Anbieten muss ihn der 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">tailscale <span class="nb">set</span> --advertise-exit-node</span></span></code></pre></div>
</div>
<p>Angeboten heißt noch nicht erlaubt. Auf dem Headscale-Server musst du die Route freigeben:</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> headscale headscale nodes list-routes
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> headscale headscale nodes approve-routes --identifier <span class="m">1</span> --routes <span class="s2">&#34;0.0.0.0/0,::/0&#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">ID | Hostname      | Approved  | Available | Serving (Primary)
</span></span><span class="line"><span class="cl">1  | kueche-server | 0.0.0.0/0 | 0.0.0.0/0 | 0.0.0.0/0
</span></span><span class="line"><span class="cl">   |               | ::/0      | ::/0      | ::/0</span></span></code></pre></div>
</div>
<p>Auf dem Client einschalten:</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">tailscale <span class="nb">set</span> --exit-node<span class="o">=</span>kueche-server</span></span></code></pre></div>
</div>
<p>Am einfachsten prüfst du das an der öffentlichen IP, mit der der Client jetzt im Internet auftaucht –
sie muss die deines Servers sein:</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 -4 -s https://ifconfig.me</span></span></code></pre></div>
</div>
<p>Dahinter steckt die Routing-Tabelle 52, die Tailscale per Policy-Routing vor die normale Tabelle
hängt:</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">ip route show table <span class="m">52</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">default dev tailscale0
</span></span><span class="line"><span class="cl">100.64.0.1 dev tailscale0
</span></span><span class="line"><span class="cl">100.100.100.100 dev tailscale0</span></span></code></pre></div>
</div>
<p>Abschalten geht mit <code>tailscale set --exit-node=</code> ohne Wert.</p>
<p><strong>Subnetz-Routen</strong> funktionieren nach demselben Muster: Der Server wirbt mit
<code>tailscale set --advertise-routes=192.168.178.0/24</code> für ein ganzes LAN, du gibst es mit
<code>approve-routes</code> frei, und die Clients holen es sich mit <code>tailscale set --accept-routes</code>. Ohne
<code>--accept-routes</code> liegt die Route zwar bereit, wird aber nicht installiert – das ist der häufigste
Grund, warum „die Route ist doch approved&quot; trotzdem nicht funktioniert.</p>
<h2 id="wenn-es-nicht-funktioniert">Wenn es nicht funktioniert</h2>
<div class="troubleshoot not-prose">
<p><strong>Headscale startet nicht und meldet <code>headscale now requires a new noise.private_key_path field</code>.</strong> In der <code>config.yaml</code> fehlt der Block <code>noise:</code> mit <code>private_key_path: /var/lib/headscale/noise_private.key</code> (Schritt 1). Den Schlüssel selbst musst du nicht anlegen, das erledigt Headscale beim Start.</p>
<p><strong>Headscale startet in einer Schleife neu, im Log steht <code>loading policy: open /etc/headscale/policy.json: no such file or directory</code>.</strong> <code>policy.path</code> zeigt auf eine Datei, die es noch nicht gibt. Leg die Alles-erlauben-Datei aus Schritt 1 an oder gleich deine echten Regeln aus Schritt 7.</p>
<p><strong><code>apt-get update</code> meldet <code>The repository 'https://pkgs.tailscale.com/...' is not signed</code>.</strong> Schau in die Zeile darüber, dort steht meist <code>Failed to parse keyring ... Permission denied (os error 13)</code>. Ursache ist eine strengere <code>UMASK</code> (etwa <code>027</code>) auf dem System: <code>curl -o</code> legt den Schlüssel dann mit Modus <code>0640</code> an, und apt prüft Signaturen als unprivilegierter Benutzer <code>_apt</code>, der die Datei nicht lesen darf. <code>chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg /etc/apt/sources.list.d/tailscale.list</code> behebt es sofort.</p>
<p><strong>Headscale startet nicht und meldet <code>server_url cannot use the same domain as base_domain</code>.</strong> <code>dns.base_domain</code> und die Domain in <code>server_url</code> überschneiden sich. Headscale verweigert das, weil sonst der eigene Control-Server über MagicDNS aufgelöst würde und sich damit selbst unerreichbar machen könnte. Wähl für <code>base_domain</code> eine eigene, nicht öffentliche Zone wie <code>kueche.internal</code> und prüf die Datei vor jedem Neustart mit <code>docker compose exec headscale headscale configtest</code>.</p>
<p><strong>In den Logs steht <code>listening without TLS but ServerURL does not start with http://</code>.</strong> Das ist keine Fehlfunktion, sondern der Normalzustand hinter einem Reverse Proxy: Headscale selbst spricht HTTP, Traefik macht das TLS. Ein echtes Problem erkennst du daran, dass <code>curl https://DEINE_DOMAIN/health</code> nicht <code>{&quot;status&quot;:&quot;pass&quot;}</code> liefert – dann liegt es an Traefik, nicht an Headscale.</p>
<p><strong>Ein Client verbindet sich nicht und bleibt bei der Anmeldung hängen.</strong> Prüf zuerst die Clientversion mit <code>tailscale version</code>: Headscale 0.29 weist alles unterhalb von 1.80 ab, und die Distributionspakete mancher LTS-Systeme liegen darunter. Steht die Version, ist der nächste Verdächtige der Pre-Auth-Key – Standardlaufzeit ist eine Stunde, und <code>preauthkeys list</code> zeigt dir in der Spalte <code>Expiration</code>, ob er noch gilt.</p>
<p><strong>Eine Subnetz-Route ist freigegeben und im Client installiert, das Ziel bleibt trotzdem unerreichbar.</strong> Wenn das Zielnetz ein Docker-Netzwerk auf demselben Host ist, blockiert Docker den Verkehr selbst. In der Filterkette steht <code>-A DOCKER ! -i br-XXXX -o br-XXXX -j DROP</code>, und Pakete, die über <code>tailscale0</code> hereinkommen, fallen genau darauf. Für Container-Netze nimmst du deshalb keine Subnetz-Route, sondern veröffentlichst den Dienst regulär über den Reverse Proxy. Bei echten LANs prüfst du stattdessen, ob auf dem Subnetz-Router <code>net.ipv4.ip_forward=1</code> gesetzt ist.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Dein Problem ist nicht dabei?
  <a href="/fehler/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Alle Fehlermeldungen durchsuchen →</a>
</p>

<h2 id="wartung--backups">Wartung &amp; Backups</h2>
<p><strong>Updates</strong> sind bei Headscale kein Selbstläufer. Das Projekt steht vor 1.0, und zwischen
Minor-Versionen ändern sich Konfigurationsschlüssel und das Policy-Format. Lies vor jedem Sprung die
Release Notes, insbesondere die Zeile zur minimalen Clientversion, und geh so vor:</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/headscale
</span></span><span class="line"><span class="cl">cp -a data data-backup-<span class="k">$(</span>date +%F<span class="k">)</span>
</span></span><span class="line"><span class="cl">sed -i <span class="s1">&#39;s/headscale:v0.29.5/headscale:vNEUE_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 <span class="nb">exec</span> headscale headscale configtest</span></span></code></pre></div>
</div>
<p>Die Datenbankmigration läuft beim Start automatisch – deshalb die Kopie vorher. Der Tailscale-Client
auf den Knoten aktualisiert sich dagegen über apt und darf ruhig vorauslaufen.</p>
<p><strong>Sichern musst du drei Dinge</strong>, und zwei davon sind unersetzlich:</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/headscale/data/db.sqlite          # Knoten, Benutzer, Routen
</span></span><span class="line"><span class="cl">/opt/headscale/data/noise_private.key  # ohne den kennt dich kein Client wieder
</span></span><span class="line"><span class="cl">/opt/headscale/data/derp_server_private.key
</span></span><span class="line"><span class="cl">/opt/headscale/config/                 # config.yaml + policy.json</span></span></code></pre></div>
</div>
<p>Verlierst du <code>noise_private.key</code>, müssen sich <strong>alle</strong> Geräte neu anmelden. Nimm den Ordner in dein
<a href="/tutorials/backups-mit-restic/">Restic-Backup</a> auf. Weil SQLite mit aktiviertem Write-Ahead-Log in
mehreren Dateien liegt, hältst du den Container für die Sicherung kurz an – <code>docker compose stop</code>,
sichern, <code>docker compose start</code>. Das kostet ein paar Sekunden Ausfall der Steuerebene; bestehende
Verbindungen zwischen den Knoten laufen dabei weiter.</p>
<p><strong>Realistischer Aufwand:</strong> Nach dem Aufsetzen sind es ein paar Minuten pro Monat – Knotenliste
durchsehen, alte Geräte mit <code>headscale nodes delete</code> entfernen, abgelaufene Schlüssel aufräumen.
Dazu ein bewusster Termin pro Quartal für das Headscale-Update, weil das eben nicht unbeaufsichtigt
laufen sollte.</p>
<p><strong>Aufräumen gehört dazu.</strong> Geräte, die du nur einmal brauchst, legst du mit
<code>preauthkeys create --ephemeral</code> an – solche Knoten verschwinden nach 30 Minuten Inaktivität von
selbst (einstellbar über <code>node.ephemeral.inactivity_timeout</code>). Alles andere bleibt in der Liste stehen, bis du es löschst, und jeder tote Eintrag ist ein
Schlüssel, der theoretisch noch gültig ist.</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>Was Headscale nicht kann
  </p>
  <div class="prose-kitchen text-sm">Headscale ist eine Nachimplementierung des Tailscale-Control-Servers und deckt den offenen Teil des
Protokolls ab. Funktionen der kommerziellen Plattform – Taildrop zwischen fremden Nutzern,
Tailscale SSH mit Session-Recording, die Weboberfläche, Funnel – gibt es hier nicht oder nur
eingeschränkt. Für ein privates Mesh mit einer Handvoll Geräten fehlt nichts Wesentliches; wer eine
Admin-Oberfläche mit Klickbedienung erwartet, wird enttäuscht sein.</div>
</div>
]]></content:encoded></item></channel></rss>