<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>leodeng.dev</title>
    <link>https://leodeng.dev/blog</link>
    <description>Leo Deng's blog: self-hosting, IoT, and building things.</description>
    <language>en</language>
  <item>
    <title>Setting Up Headscale for Remote Access to My Home Server</title>
    <link>https://leodeng.dev/blog/tailscale-headscale-setup-blog</link>
    <guid>https://leodeng.dev/blog/tailscale-headscale-setup-blog</guid>
    <pubDate>Sun, 12 Jul 2026 10:36:24 GMT</pubDate>
    <description>What actually broke while getting my Mac, Windows PC, and phone to</description>
    <content:encoded><![CDATA[<p><strong>The problem:</strong> I wanted remote access to my homelab. Not &quot;port forward SSH and hope for the best&quot; remote access, actual private mesh networking between my server, my Mac, my Windows PC, and my phone, so any of them can reach any other one like they&#39;re on the same LAN, from anywhere. Concretely, the things I actually needed this for: <strong>remote video streaming</strong> from cameras at home, controlling <strong>Home Assistant</strong> without exposing it to the open internet, a <strong>secure SSH tunnel</strong> into the server without punching random ports through my router, and being able to <strong>remotely edit and debug code</strong> running on the server as if I were sitting in front of it.</p>
<p>Tailscale is the obvious tool for this, but self-hosting made more sense for my situation. One important distinction I originally got wrong: <strong>Tailscale&#39;s coordination server is control plane, not data plane</strong> (<a href="https://tailscale.com/docs/concepts/control-data-planes">official explanation</a>). It distributes keys, peer information, policy, and connection metadata; it does <strong>not</strong> normally carry my actual traffic. Tailscale tries to move peer traffic directly over WireGuard/UDP, and DERP is the fallback when a direct path can&#39;t be established. I still wanted Headscale because I preferred to run the control plane myself, and I also wanted the option to place a relay close to my server for the network combinations that really did need one. Headscale is the open-source reimplementation of Tailscale&#39;s control server: same official clients on every device, just pointed at infrastructure I run myself.</p>
<p>Here&#39;s what actually happened, roughly in the order I hit it. Also, full transparency upfront: AI tools were a big part of actually working through most of these issues (reading logs, narrowing down root causes, sanity checking fixes before running them), and this writeup itself was drafted with the same kind of help.</p>
<blockquote>
<p><strong>Update / correction note:</strong> I later went back through this against the current Headscale, Tailscale, and Apple documentation. A couple of my original explanations were too confident: I had conflated the control plane with the data path, and I had Apple&#39;s 398-day certificate rule wrong. The version below keeps what I actually observed, but separates confirmed behavior from what was only correlation.</p>
</blockquote>
<h2>Attempt 1: Just set it up normally</h2>
<p>Got Headscale running, got my Mac connected, called it done. Except every time I restarted my Mac, it came back as a completely different device: new tailnet IP, new machine key, no memory of ever having connected before. My node list in Headscale started filling up with duplicate ghost entries with random suffixes, and any script that assumed a stable IP for my server broke constantly.</p>
<p><strong>What fixed it in my setup:</strong> finite node expiry was enabled in the configuration I was using. Current Headscale&#39;s base default is actually <code>node.expiry: 0</code> (no default expiry); its example config notes that Tailscale&#39;s hosted service uses finite key expiry (<a href="https://github.com/juanfont/headscale/blob/main/config-example.yaml">Headscale config</a>). Also, expiry by itself should normally force reauthentication rather than magically erase a client&#39;s local identity, so if a machine appears as a brand-new node after <em>every</em> reboot, client-state persistence is another thing worth checking.</p>
<p>In my case, disabling default node expiry stopped the re-registration mess:</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">node:</span>
  <span class="hljs-attr">expiry:</span> <span class="hljs-number">0</span></code></pre><p>After that I manually cleaned out the ghost entries.</p>
<p>So I would not phrase the lesson as &quot;Headscale nodes expire by default.&quot; The safer lesson is: <strong>check both the server-side node expiry and whether the client is actually preserving its local state.</strong></p>
<h2>Attempt 2: TLS just didn&#39;t work</h2>
<p>Headscale needs to be reachable over <strong>HTTPS</strong> by Tailscale clients, and TCP/443 is also required if you&#39;re using Headscale&#39;s embedded DERP server (<a href="https://headscale.net/stable/setup/requirements/">Headscale requirements</a>). I already had a private CA for internal services, so I signed a server certificate, trusted the CA on my Mac, and expected that to be the end of it.</p>
<p>It wasn&#39;t. The client still failed TLS verification, with an error that made it look like the trust chain itself was the problem.</p>
<p><strong>What I had wrong in the original writeup:</strong> I blamed Apple&#39;s modern <strong>398-day</strong> certificate rule. That&#39;s not the right rule for this case. Apple&#39;s 398-day limit applies to TLS server certificates chaining to roots that ship in Apple&#39;s trust store, and Apple explicitly says that limit <strong>does not apply</strong> to user-added or administrator-added root CAs (<a href="https://support.apple.com/en-asia/102028">Apple</a>).</p>
<p>The older macOS 10.15 / iOS 13 TLS requirements are different: for TLS server certificates issued after July 1, 2019, Apple requires a validity period of <strong>825 days or fewer</strong>, along with SAN, serverAuth EKU, SHA-2 signatures, and adequate key sizes (<a href="https://support.apple.com/en-ie/103769">Apple</a>). My leaf certificate had a ridiculous <strong>10-year</strong> lifetime, so it violated that 825-day requirement even though I trusted the private root CA.</p>
<p><strong>Fix:</strong> reissue the leaf certificate with a validity period under 825 days. I used a much shorter certificate and the client immediately started accepting it.</p>
<p>So if you&#39;re running your own PKI and OpenSSL is happy while an Apple client is not, don&#39;t just stare at the CA chain. Check the leaf certificate&#39;s SAN, EKU, signature/key requirements, <strong>and lifetime</strong>.</p>
<h2>Attempt 3: Testing outside my LAN, connections just died</h2>
<p>Took my Mac off the home network to actually test remote access, and sometimes it couldn&#39;t establish a direct connection to the server at all.</p>
<p><strong>What went wrong:</strong> my server only had a <strong>public IPv6 address, no public IPv4</strong>, while the network I was testing from was IPv4-only. Without some translation layer, those two endpoints don&#39;t have a shared IP family for a direct UDP path. DERP is useful here because Tailscale&#39;s DERP servers are dual-stack and can relay between IPv4-only and IPv6-only clients (<a href="https://tailscale.com/docs/reference/derp-servers">Tailscale DERP docs</a>).</p>
<p><strong>Fix:</strong> I added a nearby self-hosted DERP option so that when a direct path really wasn&#39;t possible, the fallback didn&#39;t have to take an unnecessarily long route.</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">derp:</span>
  <span class="hljs-attr">server:</span>
    <span class="hljs-attr">enabled:</span> <span class="hljs-literal">true</span>
    <span class="hljs-attr">region_id:</span> <span class="hljs-number">999</span>
  <span class="hljs-attr">paths:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">/etc/headscale/derp.yaml</span></code></pre><p>One wording correction from my original version: <strong>Headscale itself isn&#39;t &quot;choosing the lowest-latency relay.&quot;</strong> Headscale distributes the DERP map; the Tailscale client measures regions and selects a preferred/home DERP based largely on latency and reachability.</p>
<p>Direct is still the goal. DERP is the fallback.</p>
<h2>Background: how my Mac kept reaching Headscale reliably</h2>
<p>Before getting into the next problem, worth explaining something I&#39;d already built and mostly forgotten I was relying on.</p>
<p>My Mac was reaching the <strong>Headscale control server</strong> through an internal hostname. Because my ISP&#39;s public IPv6 prefix can change, I had a small script that kept an <code>/etc/hosts</code> entry pointed at the server&#39;s current public IPv6 address:</p>
<pre><code class="hljs language-text">2409:xxxx:xxxx:xxxx::1 headscale.lan</code></pre><p>Important correction: this only changes <strong>how the Mac reaches Headscale&#39;s control-plane hostname</strong>. It does <strong>not</strong> choose the peer-to-peer WireGuard path and it does not, by itself, make Tailscale data traffic &quot;direct.&quot; Peer traffic uses endpoint discovery/NAT traversal separately.</p>
<p>My earlier version of the script also used <code>tailscale ping</code> as a test before refreshing the address. That&#39;s not a great validity check for this purpose: a Tailscale ping can succeed over DERP even when the <code>/etc/hosts</code> entry is stale. The cleaner version is simply to query the authenticated source of truth periodically and update only when the address changed:</p>
<pre><code class="hljs language-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -euo pipefail

ENDPOINT=<span class="hljs-string">&quot;YOUR_SYNC_ENDPOINT_HERE&quot;</span>
SECRET=<span class="hljs-string">&quot;YOUR_SECRET_HERE&quot;</span>
CA_CERT=<span class="hljs-string">&quot;YOUR_CERT_PATH/ca.crt&quot;</span>
CLIENT_CERT=<span class="hljs-string">&quot;YOUR_CERT_PATH/client.crt&quot;</span>
CLIENT_KEY=<span class="hljs-string">&quot;YOUR_CERT_PATH/client.key&quot;</span>
HOSTNAME_ENTRY=<span class="hljs-string">&quot;headscale.lan&quot;</span>

CURRENT_IPV6=$(grep -E <span class="hljs-string">&quot;[[:space:]]<span class="hljs-variable">${HOSTNAME_ENTRY}</span>([[:space:]]|$)&quot;</span> /etc/hosts \
  | awk <span class="hljs-string">&#x27;{print $1}&#x27;</span> | <span class="hljs-built_in">head</span> -1 || <span class="hljs-literal">true</span>)

RESPONSE=$(curl --silent --fail --max-time 15 \
  --cacert <span class="hljs-string">&quot;<span class="hljs-variable">$CA_CERT</span>&quot;</span> \
  --cert <span class="hljs-string">&quot;<span class="hljs-variable">$CLIENT_CERT</span>&quot;</span> \
  --key <span class="hljs-string">&quot;<span class="hljs-variable">$CLIENT_KEY</span>&quot;</span> \
  -H <span class="hljs-string">&quot;X-Sync-Secret: <span class="hljs-variable">$SECRET</span>&quot;</span> \
  <span class="hljs-string">&quot;<span class="hljs-variable">$ENDPOINT</span>&quot;</span>)

NEW_IPV6=$(<span class="hljs-built_in">printf</span> <span class="hljs-string">&#x27;%s&#x27;</span> <span class="hljs-string">&quot;<span class="hljs-variable">$RESPONSE</span>&quot;</span> \
  | python3 -c <span class="hljs-string">&quot;import sys,json; print(json.load(sys.stdin)[&#x27;ipv6&#x27;])&quot;</span>)

<span class="hljs-keyword">if</span> [[ -z <span class="hljs-string">&quot;<span class="hljs-variable">$NEW_IPV6</span>&quot;</span> || <span class="hljs-string">&quot;<span class="hljs-variable">$NEW_IPV6</span>&quot;</span> == <span class="hljs-string">&quot;<span class="hljs-variable">$CURRENT_IPV6</span>&quot;</span> ]]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">exit</span> 0
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">sudo</span> /bin/sh -c <span class="hljs-string">&quot;
  awk -v host=&#x27;<span class="hljs-variable">$HOSTNAME_ENTRY</span>&#x27; &#x27;\$2 != host { print }&#x27; /etc/hosts &gt; /tmp/hosts.tmp
  printf &#x27;%s %s\n&#x27; &#x27;<span class="hljs-variable">$NEW_IPV6</span>&#x27; &#x27;<span class="hljs-variable">$HOSTNAME_ENTRY</span>&#x27; &gt;&gt; /tmp/hosts.tmp
  cat /tmp/hosts.tmp &gt; /etc/hosts
  rm -f /tmp/hosts.tmp
&quot;</span></code></pre><p>That endpoint is authenticated with a client certificate; by &quot;private&quot; here I mean it is <strong>not anonymously queryable</strong>, not that the URL necessarily has to be unreachable from the public internet.</p>
<p>I wired the script into <code>launchd</code> so it runs periodically. I hadn&#39;t originally built it specifically for Headscale; it was a general-purpose &quot;keep this hostname pointed at my changing server address&quot; helper I&#39;d made for an earlier project, and I reused the pattern here.</p>
<h2>Attempt 4: Adding Windows and Android broke everything</h2>
<p>Mac was solid. Tried adding Windows and my phone next, and both got stuck on &quot;connecting&quot; forever, with very little useful UI feedback.</p>
<p><strong>What went wrong:</strong> the hostname I was using existed only in my Mac&#39;s <code>/etc/hosts</code>. Windows and Android had no way to resolve it before they had joined the tailnet.</p>
<p>Tailscale clients do have a <strong>bootstrap DNS fallback</strong>: if normal DNS resolution is broken, the client can ask known Tailscale/DERP infrastructure to resolve a name. That is useful for public DNS names, but it obviously cannot invent a private-only name that has never been delegated in public DNS. The important correction is that Tailscale does <strong>not</strong> require every custom control-server hostname to be public in all circumstances; a private name can work if the device&#39;s normal system DNS (or hosts file) can already resolve it. Mine couldn&#39;t.</p>
<p>My Mac never hit the problem because <code>/etc/hosts</code> short-circuited DNS. Windows and Android hit it immediately.</p>
<p><strong>Fix:</strong> use a real domain/subdomain for the control server and issue a proper certificate for that name. I used DNS-01 for certificate issuance. After the hostname became resolvable on those devices, Windows and Android could reach the custom control server normally.</p>
<h2>Attempt 5: Fixed that, then lost all my connection speed</h2>
<p>After the hostname migration, I saw connections that had previously been direct showing up as relayed, and throughput got much worse.</p>
<p>My original explanation here was:</p>
<blockquote>
<p>&quot;The public DNS record changed, therefore Tailscale started relaying. Restoring an <code>/etc/hosts</code> override made it direct again.&quot;</p>
</blockquote>
<p>That explanation was too confident and, architecturally, mostly wrong.</p>
<p><strong>Why:</strong> the hostname of the Headscale server is part of the <strong>control plane</strong>. It determines how the client reaches Headscale for registration, keys, peer information, policy, DERP maps, and updates. It is <strong>not</strong> the address used for normal peer-to-peer WireGuard data traffic.</p>
<p>The actual peer path is negotiated separately. Tailscale learns candidate peer endpoints, uses STUN/NAT traversal, and tries direct UDP. If a direct path can&#39;t be established, the connection can fall back to a relay such as DERP (<a href="https://tailscale.com/docs/reference/connection-types">connection types</a>). Changing:</p>
<pre><code class="hljs language-text">ts.example.com -&gt; some address</code></pre><p>can absolutely make the <strong>control server</strong> reachable or unreachable, but it should not by itself convert a healthy direct peer path into a DERP path.</p>
<p>So the correct troubleshooting path for &quot;why am I relayed?&quot; is things like:</p>
<pre><code class="hljs language-bash">tailscale status
tailscale ping &lt;peer&gt;
tailscale netcheck
tailscale debug derp-map</code></pre><p>Then check UDP reachability, NAT behavior, firewalls, the peer&#39;s discovered endpoints, and which DERP region is being selected.</p>
<p>In my case, the relay-only behavior changed around the same time as the DNS migration, and the hosts override appeared to &quot;fix&quot; it, but I didn&#39;t capture enough state to prove that DNS was the cause. The honest conclusion is: <strong>correlation, not a confirmed root cause.</strong></p>
<p>I still use a hosts/split-DNS override where it is useful for reaching the Headscale control server at the right address. I just no longer describe that as a data-plane performance optimization.</p>
<p>My Windows desktop is simpler because it sits on the same LAN as the server. When both peers can reach each other locally and UDP isn&#39;t being blocked, Tailscale can establish a local direct path regardless of what hostname I use for the control server.</p>
<p>Android is also where the distinction matters most: without root I can&#39;t casually rewrite <code>/etc/hosts</code>. If the public DNS record for the Headscale hostname points only to an unroutable/private address, an Android phone away from home may be unable to reach the <strong>control plane at all</strong>. Cached tailnet state can keep some existing connectivity working when the coordination server is unavailable, but new peer information, policy changes, key operations, and other control-plane updates won&#39;t work reliably until the control server is reachable again (<a href="https://tailscale.com/docs/reference/coordination-server-down">Tailscale&#39;s coordination-outage behavior</a>).</p>
<p>So for a real deployment, the cleaner choices are a genuinely reachable public control-plane endpoint, a reverse proxy/VPS in front of Headscale, or proper split-horizon DNS rather than pretending an unreachable public record is harmless.</p>
<h2>Attempt 6: Windows straight up ignored me</h2>
<p>After the hostname migration, Windows kept trying to connect to the old control address no matter what I told it. Explicit login-server flags and the resets I tried weren&#39;t enough.</p>
<p><strong>What went wrong:</strong> the old control-server information was persisted in the client&#39;s local state, so changing a command-line flag wasn&#39;t necessarily equivalent to giving the client a completely fresh identity/configuration.</p>
<p><strong>Fix:</strong> I stopped the Tailscale service, made sure the client processes were gone, removed the relevant local Tailscale state, and re-enrolled the machine against the new control server.</p>
<p>The important warning: <strong>wiping the state directory is destructive.</strong> It signs the client out and throws away local Tailscale state, so don&#39;t present it as a harmless first-line &quot;reset&quot; command. Use the normal logout/reset/re-login path first; only wipe local state when you are intentionally okay with treating the machine as a fresh client.</p>
<p>Sometimes a reset really does not mean &quot;delete every persisted bit of state.&quot;</p>
<h2>Where things ended up</h2>
<p>The setup is now much easier for me to reason about because I keep the two planes separate in my head:</p>
<ul>
<li><strong>Control plane:</strong> Headscale over one real hostname and a valid TLS certificate. Any device that needs to manage/join/update remotely must actually be able to reach that hostname somehow: public address, reverse proxy, VPN-independent tunnel, split DNS, or an explicit local override.</li>
<li><strong>Data plane:</strong> Tailscale/WireGuard peer traffic tries to go direct over UDP. If that is impossible (for example, an IPv4-only client talking to an IPv6-only server with no translation path), a relay handles the traffic.</li>
</ul>
<p>The self-hosted DERP exists for the second problem. The hostname and TLS work exist for the first one. They are related because the control server distributes network information, but they are not the same path.</p>
<p>That was the biggest conceptual mistake in my first version of this post: I treated &quot;where the Headscale hostname resolves&quot; as if it were also &quot;where my Tailscale traffic flows.&quot; It isn&#39;t.</p>
<p>None of the failures were magic. Some were configuration mistakes, some were platform requirements I hadn&#39;t read carefully enough, and at least one &quot;root cause&quot; was me seeing a fix happen at the same time as a network-path change and assuming one caused the other.</p>
<p>Which, honestly, is a pretty normal homelab debugging session.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Hello, World!</title>
    <link>https://leodeng.dev/blog/hello-world</link>
    <guid>https://leodeng.dev/blog/hello-world</guid>
    <pubDate>Mon, 25 May 2026 20:09:55 GMT</pubDate>
    <description>First post in the NEW blog!</description>
    <content:encoded><![CDATA[<p>Hello, welcome to the blog.</p>
<p>I&#39;ve been thinking about writing a blog for a long time but never really committed to it haha, but this will be a new place for me to occasionally write some interesting stuff.</p>
<h2>What to expect from my blogs</h2>
<p>Probably a mix of:</p>
<ul>
<li>Build logs / project updates (ShutterWingPhotos, llmgps, basically whatever i am working on)</li>
<li>Self-hosting stuff — things that took me WAYYYY too long to figure out</li>
<li>IoT and HomeAssistant experiments</li>
<li>Occasional hot takes on AI tools</li>
</ul>
<p>Nothing too formal. If something trips me up and took me a while to figure out, I&#39;ll write it down so future me (and maybe you) don&#39;t have to go through it again.</p>
<p>Anyways... first post is done. More content coming sometime soon :)</p>
]]></content:encoded>
  </item>
  </channel>
</rss>
