<?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/en/tags/tailscale/</link><description>Tailscale – 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>Tue, 20 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/tailscale/index.xml" rel="self" type="application/rss+xml"/><item><title>Headscale: Your Own Tailscale Network</title><link>https://serverkueche.de/en/tutorials/headscale-self-hosted/</link><pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/headscale-self-hosted/</guid><description>Headscale 0.29.5 as your own control server behind Traefik: join nodes, MagicDNS, an embedded DERP relay, ACLs and an exit node – without anyone else's cloud.</description><content:encoded><![CDATA[<p>WireGuard is wonderful right up until you add the fifth device: every peer needs every other peer&rsquo;s
key, every new IP has to be maintained everywhere, and behind two layers of NAT no connection comes
up at all. Tailscale solves exactly that – and Headscale solves it without your device list living
at someone else&rsquo;s company.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We run <strong>Headscale 0.29.5</strong> as our own control server behind Traefik. Two nodes running the official
<strong>Tailscale client 1.104.1</strong> attach to it and address each other by name via MagicDNS. The relay
server (DERP) runs embedded in the same container, an ACL file governs who may reach whom, and
finally the server acts as an exit node.</p>
<p>One thing matters for understanding all of it: <strong>Headscale is only the control plane.</strong> It
distributes public keys, IP addresses and rules. The actual traffic then flows as a direct WireGuard
connection between the devices – past the server. If Headscale goes down, existing nodes keep
talking to each other; only new devices and rule changes need it.</p>
<p>The difference from a <a href="/en/tutorials/wireguard-vpn-setup/">classic WireGuard setup</a> is therefore:
there, a star with the server in the middle and manual work per peer; here, a mesh in which devices
find each other and connect directly even behind NAT.</p>
<h2 id="requirements">Requirements</h2>
<ul>
<li>A server with <a href="/en/tutorials/traefik-reverse-proxy/">Traefik as a reverse proxy</a> and a domain
pointing at it – Headscale <strong>must</strong> be reachable over HTTPS</li>
<li><a href="/en/tutorials/wireguard-vpn-setup/">WireGuard basics</a>: you should know what a peer, a public key
and an AllowedIP are</li>
<li>Familiarity with <a href="/en/tutorials/docker-compose-basics/">Docker Compose</a></li>
<li>A second device (laptop, second server, phone) that can join the network</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/headscale-self-hosted/" 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>🍳 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 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Headscale itself is frugal – a VPS 1000 is plenty for the control plane and relay.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-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">
  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-1000" 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 1000 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 1000 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-write-the-configuration">Step 1: Write the configuration</h3>
<p>Headscale is configured through a single YAML file. Create the directories:</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>The configuration is where most setups fail – so here it is in blocks, with reasoning. Replace
<code>YOUR_DOMAIN</code> with the domain Headscale should be reachable at, and <code>YOUR_SERVER_IP</code> with your
server&rsquo;s public IPv4 address.</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://YOUR_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> is the address every client will use later – it appears in every <code>tailscale up</code> call and
can only be changed afterwards with considerable pain. <code>listen_addr</code> binds to all interfaces of the
container so Traefik can reach it; from the outside, only Traefik is exposed.</p>
<p><code>noise.private_key_path</code> is mandatory: without it Headscale doesn&rsquo;t start and reports
<code>headscale now requires a new noise.private_key_path field in the config file for the Tailscale v2 protocol</code>. Headscale creates the key itself on first start.</p>
<p><code>trusted_proxies</code> is the entry people tend to forget: without it, Headscale ignores the
<code>X-Forwarded-For</code> header and logs every login with Traefik&rsquo;s IP. Put in the subnet of your Docker
proxy network – <code>docker network inspect proxy --format '{{ range .IPAM.Config }}{{ .Subnet }}{{ end }}'</code>
shows it.</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>Warning
  </p>
  <div class="prose-kitchen text-sm">Only set <code>trusted_proxies</code> if a proxy really sits in front. Without one you are allowing every
client to forge its own source IP in your logs.</div>
</div>
<p>On to the address ranges and the 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;home&#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;Own 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">YOUR_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>The prefixes <strong>must</strong> stay within <code>100.64.0.0/10</code> and <code>fd7a:115c:a1e0::/48</code> – the Tailscale client
hard-codes those ranges and breaks in subtle ways with anything else.</p>
<p><strong>DERP</strong> is Tailscale&rsquo;s relay: when two devices cannot establish a direct connection (symmetric NAT,
restrictive firewalls), traffic flows encrypted through a relay server. By default those are
Tailscale Inc.&rsquo;s servers. With <code>enabled: true</code> you run your own, and <code>urls: []</code> makes sure the
external list is never loaded – then not a single byte of your network leaves your own
infrastructure. <code>verify_clients: false</code> is required because that check needs a local <code>tailscaled</code>
socket, which does not exist inside the container.</p>
<p>Finally the database, rules and 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">home.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>The policy file from <code>policy.path</code> <strong>must exist at startup</strong>, otherwise Headscale aborts with
<code>loading policy: open /etc/headscale/policy.json: no such file or directory</code>. Until you write real
rules in step 7, create <code>/opt/headscale/config/policy.json</code> with this content – it allows
everything:</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 is not a compromise here but the recommended choice – the Headscale developers build and test
against SQLite; Postgres is only kept around for legacy reasons.</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 must not match server_url
  </p>
  <div class="prose-kitchen text-sm"><code>base_domain</code> is the zone for MagicDNS and must be a <strong>different</strong> domain from the one in
<code>server_url</code>. Otherwise Headscale refuses to start at all and reports:
<code>server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable</code>.
Pick something purely internal such as <code>home.internal</code>.</div>
</div>
<h3 id="step-2-start-the-stack">Step 2: Start 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/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(`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.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>The image tag is deliberately pinned to <code>v0.29.5</code>. Headscale is pre-1.0 and does change
configuration formats between minor versions – <code>latest</code> is a bad idea here.</p>
<p>The only directly published port is <strong>UDP 3478</strong> for STUN. The DERP server needs it to help clients
punch through NAT; HTTPS and the DERP channel itself go through Traefik. If you use
<a href="/en/tutorials/firewall-ufw-setup/">UFW</a>, 3478/udp has to be open – and if you have assigned a
policy in the <a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>, it needs an inbound rule for
UDP 3478 there as well. Without it the network still works, but every connection goes through the
relay instead of directly (see step 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>The logs should show this:</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>The warning on the second-to-last line is <strong>correct and expected</strong>: Headscale speaks plain HTTP internally, Traefik
does the TLS. Note the line above it too – clients older than <strong>1.80</strong> are rejected.</p>
<p>Now check from the outside that the chain holds:</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://YOUR_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>If you get a certificate error or a 404 here, that is a Traefik problem and not a Headscale one –
how to tell them apart is further down.</p>
<h3 id="step-3-users-and-auth-keys">Step 3: Users and auth keys</h3>
<p>Headscale assigns every node to a user. One user is plenty for a private network:</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 home
</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  |      | home     |       | 2026-09-10 02:20:44</span></span></code></pre></div>
</div>
<p>New devices register with a <strong>pre-auth key</strong>. Note that <code>--user</code> expects the <strong>ID</strong> from the list,
not the name:</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>Caution
  </p>
  <div class="prose-kitchen text-sm">This key is full network access. Whoever holds it can attach their own device to your VPN. Keep the
lifetime short (<code>--expiration 1h</code>), do not send it over chat or email, and drop <code>--reusable</code> if you
are only connecting a single device. After creation you see it in full <strong>exactly once</strong> –
<code>preauthkeys list</code> only shows the prefix from then on.</div>
</div>
<h3 id="step-4-join-the-first-node">Step 4: Join the first node</h3>
<p>On the device that should join, install the official Tailscale client. The key comes as a binary
file, so no <code>gnupg</code> is needed:</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>The <code>chmod</code> looks redundant but is not: on hardened systems with <code>UMASK 027</code>, <code>curl</code> creates the
files with mode <code>0640</code> and apt can no longer read them. Details on that below under
&ldquo;When it doesn&rsquo;t work&rdquo;.</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>Now the join. The decisive switch is <code>--login-server</code>, which points the client at your Headscale
instead of Tailscale&rsquo;s cloud:</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://YOUR_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --authkey<span class="o">=</span>YOUR_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>home-server</span></span></code></pre></div>
</div>
<p><code>--accept-dns=false</code> matters on <strong>servers</strong>: otherwise <code>tailscaled</code> rewrites <code>/etc/resolv.conf</code> and
routes name resolution through the VPN. On a server that has to keep running, that is rarely what
you want. On laptops and phones you leave the option out – there MagicDNS is the whole point.</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="step-5-a-second-node-and-magicdns">Step 5: A second node and MagicDNS</h3>
<p>The second node is installed the same way, just with a different hostname and without
<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://YOUR_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --authkey<span class="o">=</span>YOUR_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>On the server you now see both nodes:</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  | home-server | home-server | [k54+k]    | [71w+9] | home |      | 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] | home |      | 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>On the laptop, <code>tailscaled</code> has meanwhile redirected the DNS resolver:</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 home.internal</span></span></code></pre></div>
</div>
<p><code>100.100.100.100</code> is the built-in MagicDNS resolver. That makes the thing you actually wanted work:</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> home-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>The short name is enough; <code>home-server.home.internal</code> works just as well. Whether the connection is
truly direct or goes through the relay is answered by <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 home-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 home-server (100.64.0.1) via 172.19.0.1:41641 in 0s</span></span></code></pre></div>
</div>
<p>If no direct connection can be established, the relay does its job and everything still works, just
with a little more latency. Then it looks like this:</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 home-server (100.64.0.1) via DERP(home) in 1ms
</span></span><span class="line"><span class="cl">direct connection not established</span></span></code></pre></div>
</div>
<p>On a netcup server the most common reason is a firewall policy in the SCP that blocks inbound UDP.</p>
<h3 id="step-6-confirm-your-own-relay-is-being-used">Step 6: Confirm your own relay is being used</h3>
<p>The point of the embedded DERP server is that Tailscale&rsquo;s infrastructure does not step in when
things get difficult. Whether that holds is shown by the 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: Own DERP
</span></span><span class="line"><span class="cl">* DERP latency:
</span></span><span class="line"><span class="cl">    - home: 400µs   (Own DERP)</span></span></code></pre></div>
</div>
<p>One region only, and it is yours. If the output further up says <code>UDP: false</code>, the client can&rsquo;t
reach your STUN port – the case from step 2 where the netcup firewall doesn&rsquo;t let UDP 3478 through. If you see a list with <code>fra</code>, <code>ams</code> or similar codes, the client
loaded the public DERP list – then <code>urls: []</code> is missing from your configuration.</p>
<h3 id="step-7-acls--who-may-talk-to-whom">Step 7: ACLs – who may talk to whom</h3>
<p>So far your policy file from step 1 lets <strong>every node do everything</strong>. As soon as real rules are in
it, default-deny applies: whatever is not explicitly allowed is dropped. A minimal, sensible rule –
it replaces the content of <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:household&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;home@&#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:household&#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:household: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:household&#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>Note the <code>@</code> after the username – it is mandatory since policy format v2. Validate the file before
restarting:</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>And now verify the rule actually bites. Port 443 is allowed, 9090 is not. For the check to prove
anything, something has to listen on 9090 – otherwise it reports <code>BLOCKED</code> simply because nobody
answers. Start a quick test service on 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">python3 -m http.server <span class="m">9090</span> --bind 100.64.0.1</span></span></code></pre></div>
</div>
<p>Then check from the laptop:</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> OPEN <span class="o">||</span> <span class="nb">echo</span> BLOCKED
</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> OPEN <span class="o">||</span> <span class="nb">echo</span> BLOCKED</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">OPEN
</span></span><span class="line"><span class="cl">BLOCKED</span></span></code></pre></div>
</div>
<p>With the allow-everything file from step 1 the second line would read <code>OPEN</code> – that&rsquo;s how you know
the rule makes the difference. Stop the test service afterwards with Ctrl+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 does not follow the port rule
  </p>
  <div class="prose-kitchen text-sm">A <code>ping</code> to <code>100.64.0.1</code> still succeeds even though the rule only lists 22, 80 and 443. That is not a
bug: the Tailscale packet filter permits ICMP as soon as the source may reach <strong>any</strong> port on the
destination. Take the counter-test – remove the destination from the rules entirely and the ping
disappears too (<code>2 packets transmitted, 0 received</code>). A successful ping therefore only proves that
some rule matches, not which one.</div>
</div>
<h3 id="step-8-the-server-as-an-exit-node">Step 8: The server as an exit node</h3>
<p>An exit node sends a device&rsquo;s entire internet traffic through the server – handy on someone else&rsquo;s
Wi-Fi. The server has to offer it:</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>Offered is not the same as allowed. On the Headscale server you have to approve the route:</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  | home-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>Enable it on the 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 <span class="nb">set</span> --exit-node<span class="o">=</span>home-server</span></span></code></pre></div>
</div>
<p>The easiest check is the public IP the client now shows on the internet – it has to be your
server&rsquo;s:</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>Behind it is routing table 52, which Tailscale puts in front of the normal table via policy
routing:</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>Turn it off again with <code>tailscale set --exit-node=</code> and no value.</p>
<p><strong>Subnet routes</strong> follow the same pattern: the server advertises an entire LAN with
<code>tailscale set --advertise-routes=192.168.178.0/24</code>, you approve it with <code>approve-routes</code>, and
clients pick it up with <code>tailscale set --accept-routes</code>. Without <code>--accept-routes</code> the route is
offered but never installed – that is the most common reason why &ldquo;but the route is approved&rdquo; still
does not work.</p>
<h2 id="when-it-doesnt-work">When it doesn&rsquo;t work</h2>
<div class="troubleshoot not-prose">
<p><strong>Headscale doesn&rsquo;t start and reports <code>headscale now requires a new noise.private_key_path field</code>.</strong> The <code>noise:</code> block with <code>private_key_path: /var/lib/headscale/noise_private.key</code> is missing from <code>config.yaml</code> (step 1). You don&rsquo;t have to create the key yourself, Headscale does that on startup.</p>
<p><strong>Headscale restarts in a loop, the log says <code>loading policy: open /etc/headscale/policy.json: no such file or directory</code>.</strong> <code>policy.path</code> points to a file that doesn&rsquo;t exist yet. Create the allow-everything file from step 1 or your real rules from step 7 right away.</p>
<p><strong><code>apt-get update</code> reports <code>The repository 'https://pkgs.tailscale.com/...' is not signed</code>.</strong> Look at the line above it, which usually says <code>Failed to parse keyring ... Permission denied (os error 13)</code>. The cause is a stricter <code>UMASK</code> (such as <code>027</code>) on the system: <code>curl -o</code> then creates the key with mode <code>0640</code>, and apt verifies signatures as the unprivileged user <code>_apt</code>, which cannot read the file. <code>chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg /etc/apt/sources.list.d/tailscale.list</code> fixes it immediately.</p>
<p><strong>Headscale refuses to start and reports <code>server_url cannot use the same domain as base_domain</code>.</strong> <code>dns.base_domain</code> and the domain in <code>server_url</code> overlap. Headscale rejects that because the control server itself would then be resolved through MagicDNS and could make itself unreachable. Pick a separate, non-public zone such as <code>home.internal</code> for <code>base_domain</code> and validate the file before every restart with <code>docker compose exec headscale headscale configtest</code>.</p>
<p><strong>The logs say <code>listening without TLS but ServerURL does not start with http://</code>.</strong> That is not a malfunction but the normal state behind a reverse proxy: Headscale speaks HTTP, Traefik handles TLS. You recognise a real problem by <code>curl https://YOUR_DOMAIN/health</code> not returning <code>{&quot;status&quot;:&quot;pass&quot;}</code> – in that case it is Traefik, not Headscale.</p>
<p><strong>A client will not connect and hangs during registration.</strong> Check the client version first with <code>tailscale version</code>: Headscale 0.29 rejects anything below 1.80, and the distribution packages of some LTS systems are older than that. If the version is fine, the next suspect is the pre-auth key – the default lifetime is one hour, and <code>preauthkeys list</code> shows you in the <code>Expiration</code> column whether it is still valid.</p>
<p><strong>A subnet route is approved and installed on the client, yet the destination stays unreachable.</strong> If the target network is a Docker network on the same host, Docker blocks the traffic itself. Its filter chain contains <code>-A DOCKER ! -i br-XXXX -o br-XXXX -j DROP</code>, and packets arriving via <code>tailscale0</code> hit exactly that rule. For container networks you therefore do not use a subnet route but publish the service through the reverse proxy as usual. For real LANs, check instead whether <code>net.ipv4.ip_forward=1</code> is set on the subnet router.</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><strong>Updates</strong> are not a set-and-forget affair with Headscale. The project is pre-1.0, and configuration
keys and the policy format do change between minor versions. Read the release notes before every
jump, especially the line about the minimum client version, and proceed like this:</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: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 <span class="nb">exec</span> headscale headscale configtest</span></span></code></pre></div>
</div>
<p>The database migration runs automatically at startup – hence the copy beforehand. The Tailscale
client on your nodes, by contrast, updates through apt and may happily run ahead.</p>
<p><strong>Three things need backing up</strong>, and two of them are irreplaceable:</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          # nodes, users, routes
</span></span><span class="line"><span class="cl">/opt/headscale/data/noise_private.key  # without it no client recognises you
</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>Lose <code>noise_private.key</code> and <strong>every</strong> device has to register again. Add the directory to your
<a href="/en/tutorials/restic-backups/">Restic backup</a>. Because SQLite with write-ahead logging spans several
files, stop the container for the backup – <code>docker compose stop</code>, back up, <code>docker compose start</code>.
That costs a few seconds of control-plane downtime; existing connections between nodes keep running
throughout.</p>
<p><strong>Realistic effort:</strong> after the initial setup it is a few minutes a month – review the node list,
remove old devices with <code>headscale nodes delete</code>, clean up expired keys. Plus a deliberate slot each
quarter for the Headscale update, precisely because that one should not run unattended.</p>
<p><strong>Cleaning up is part of it.</strong> Devices you only need once get a key created with
<code>preauthkeys create --ephemeral</code> – such nodes disappear on their own after 30 minutes of inactivity
(configurable via <code>node.ephemeral.inactivity_timeout</code>). Everything else stays in the list until you
delete it, and every dead entry is a key that is theoretically still valid.</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>What Headscale cannot do
  </p>
  <div class="prose-kitchen text-sm">Headscale is a reimplementation of the Tailscale control server and covers the open part of the
protocol. Features of the commercial platform – Taildrop between different users, Tailscale SSH with
session recording, the web interface, Funnel – are missing or only partly available. For a private
mesh with a handful of devices nothing essential is lacking; anyone expecting a point-and-click
admin interface will be disappointed.</div>
</div>
]]></content:encoded></item></channel></rss>