tutorials9 min read
Headscale v0.29: Reach Your Homelab From Anywhere Without Opening a Port (and Survive the Breaking Upgrade)
No exposed edge port, no NAT hairpinning — Headscale gives you an encrypted WireGuard mesh to your whole lab from any device, with split DNS that actually resolves on mobile. v0.29 is a breaking release; here's how to land on v0.29.3 cleanly.
Install Headscale pinned to v0.29.3 behind a reverse proxy (set trusted_proxies — new in 0.29), back up the DB before upgrading, move randomize_client_port into the policy file as randomizeClientPort or it refuses to start, audit ACLs because * now means CGNAT+ULA only, enroll nodes with tailscale up --login-server, and verify from a phone on cellular: resolve + ping an internal MagicDNS hostname, then SSH in. ~1–2 hours.
No exposed edge port, no NAT hairpinning — your whole lab is reachable from any device (including the phone) over an encrypted WireGuard mesh you control, with split DNS that actually resolves on mobile. That's Headscale: a self-hosted coordination server for Tailscale clients, and it just shipped its most consequential release in years.
Before you start//
- One always-on box to host Headscale — Pi or mini PC. This tutorial uses Docker; the official install guide covers bare installs and the backup commands are different for each (both shown below).
- Tailscale client ≥ v1.80.0 on every node — that's the minimum supported by v0.29.x, per the release notes. Check with
tailscale versionbefore you start; old clients won't register cleanly. - A public DNS name + reverse proxy (Caddy, Nginx, Traefik — your pick) for the Headscale web UI and OIDC callback if you want SSO instead of pre-auth keys. If you're already running one in the lab, this is five minutes of config.
- Skill floor: comfortable with Docker compose or a bare install + basic firewall. ~1–2 hours including enrolling your first couple of nodes.
- Blast radius: Headscale is the control plane — if it's down, existing nodes keep their WireGuard sessions but new ones can't join and policy reloads stop working. A bad policy file change is how you accidentally lock yourself out of SSH to half the lab, so: snapshot first (step 2), validate before you commit (
headscale policy check), and test from a device that's not your only way in. - Version discipline: everything below is verified against Headscale v0.29.3 — the latest release tag as of writing (GitHub releases, published 2026-07-29). v0.29.4 is unreleased on
main, and 0.30-dev will rip out gRPC entirely — one more reason to get onto 0.29 cleanly now rather than stacking two breaking upgrades later.
1. Install Headscale pinned to v0.29.3 (behind your proxy)//
Create the layout the container guide expects, then run it with a pinned image tag — never latest for a control plane:
# docker-compose.yaml
services:
headscale:
image: docker.io/headscale/headscale:v0.29.3
restart: unless-stopped
container_name: headscale
read_only: true
tmpfs:
- /var/run/headscale
ports:
- "127.0.0.1:8080:8080" # 0.0.0.0 if your proxy is on another host
- "127.0.0.1:9090:9090"
volumes:
- /opt/headscale/config:/etc/headscale:ro
- /opt/headscale/lib:/var/lib/headscale
command: serve
healthcheck:
test: ["CMD", "headscale", "health"]Download the example config for your exact tag (the docs warn main drifts ahead of releases):
mkdir -p /opt/headscale/{config,lib}
curl -o /opt/headscale/config/config.yaml \
https://raw.githubusercontent.com/juanfont/headscale/v0.29.3/config-example.yaml
docker compose up -d
docker logs --follow headscale # wait for "Listening" + health OKThen point your reverse proxy at 127.0.0.1:8080 on a public name — say https://headscale.example.com — and set these in config.yaml:
server_url: https://headscale.example.com(clients connect here; it must match the cert)listen_addr: 0.0.0.0:8080(production value, per the config comments)dns.base_domain:something different from your server URL domain — e.g.lab.example.com. MagicDNS names become<hostname>.<base_domain>, and reusing your public domain is a classic foot-gun.
Why: clients talk to Headscale over one HTTPS endpoint (the Noise protocol), so the proxy is the only internet-facing surface. DERP relay fallback handles the NAT traversal for you — no UDP hairpin config anywhere on your edge.
2. Back up the database before any upgrade//
If this is a fresh install, skip ahead — but if you're upgrading an existing Headscale (the likely reason you're reading this), the upgrade guide makes DB backup non-negotiable: v0.29 applies migrations and enforces a strict version path.
# Standard install
TIMESTAMP=$(date +%Y%m%d%H%M%S)
cp -aR /etc/headscale /etc/headscale.backup-$TIMESTAMP
cp -aR /var/lib/headscale /var/lib/headscale.backup-$TIMESTAMP
# Container install (single source volume: config + data + SQLite)
cp -aR /opt/headscale /opt/headscale.backup-$(date +%Y%m%d%H%M%S)Why: migrations run at startup and a half-migrated DB is the difference between "restart" and "rebuild." For Postgres-backed setups, follow PostgreSQL's own backup docs.
3. Migrate config → policy file (the part that breaks)//
v0.29 moved a knob out of config.yaml into the policy file, and it's unforgiving: if the old key is still set, Headscale refuses to start (#3251, per the CHANGELOG).
- Create a policy file and point
policy.pathat it (policy.mode: file):
{
"acls": [
{ "action": "accept", "src": ["*"], "dst": ["*"] }
]
}- If you had
randomize_client_port:in your config, delete it and put the equivalent at the top level of the policy file instead:
{
"randomizeClientPort": true,
"acls": [ { "action": "accept", "src": ["*"], "dst": ["*"] } ]
}-
Audit your ACLs for
*. In v0.29 the wildcard now resolves to Tailscale's CGNAT range (100.64.0.0/10) and ULA range (fd7a:115c:a1e0::/48) — tailnet addresses only — instead of all IPs (#3036). If your intent was "any IP address," switch toautogroup:danger-all(source-only — it can't be a destination) or explicit CIDRs. The name is deliberate; accepting traffic from the entire internet should feel like a security decision, not an accident. -
Validate before you commit:
headscale policy check --bypass-grpc-and-access-database-directlyWhy: this flag makes policy check validate against your live user database (including user@ tokens and the beta tests block) instead of empty data — it's how a bad policy gets caught before Headscale does. Note while you're in there: v0.29 also rejects tags/hosts/IPs as sources for autogroup:self destinations, so an ACL that loaded fine on 0.28 can fail validation now (#3036).
4. If you're below 0.29: upgrade one minor at a time//
v0.29 enforces a strict version upgrade path (#3083): skipping a minor (e.g. 0.27 → 0.29) or downgrading now blocks the upgrade entirely; patch bumps within a minor are always fine. The upgrade guide spells out the loop: read the release notes for each version, stop Headscale, back up (step 2), update to the next stable in sequence (0.26 → 0.27 → 0.28 → 0.29.x), diff your config against that tag's config-example.yaml, start it.
Two upgrade-era gotchas from the changelog: nodes upgraded from < 0.28 may show as expired until their expiry data is backfilled (handled in 0.29, and v0.29.3 additionally fixed tagged nodes getting stuck expired after tailscale logout #3394). And expect MagicDNS labels to change for any node whose old name used the random-hash suffix — collision names now get monotonic numeric suffixes (laptop, laptop-1) matching Tailscale SaaS (#3202).
Why: doing it in one hop is faster until it isn't — a blocked upgrade on a control plane you're SSHing through is a very bad evening.
5. Enroll nodes//
Create your first Headscale user and a pre-auth key, then join machines:
# On the headscale host (container users: docker exec -it headscale headscale ...)
headscale users create dchri
headscale preauthkeys create --user <USER_ID> # returns the key; 1h TTL, single-use by defaultOn each node (client ≥ v1.80):
tailscale up --login-server https://headscale.example.com --authkey <YOUR_AUTH_KEY>Or skip keys entirely and use web auth: run tailscale up --login-server https://headscale.example.com on the client, open the browser URL it prints, then approve from the server:
headscale auth register --user dchri --auth-id <AUTH_ID> # or: headscale auth reject --auth-id <AUTH_ID>Why: headscale nodes register is deprecated in favor of the headscale auth command group (#1850) — same flow, longer shelf life. Pre-auth keys are fine for a lab; OIDC through your existing identity provider (Authentik, Keycloak) is the nicer long-term setup since you stop handing out one-time secrets.
6. Split DNS on mobile//
This is where "reach home from anywhere" becomes actually true on the phone. With MagicDNS on (dns.magic_dns: true, the default), every node gets a <hostname>.<base_domain> name that resolves through the tailnet — no port open, no public record needed. The Tailscale client handles the split automatically: lab hostnames resolve via Headscale's resolver at 100.100.100.100, everything else goes to your normal upstream DNS (MagicDNS docs).
Two config knobs worth touching in config.yaml (both from the v0.29.3 example):
dns.base_domain: lab.example.com— set to something different than your server URL domain, or MagicDNS names collide with real DNS.dns.nameservers.split:— restricted per-domain resolvers if part of the lab should resolve elsewhere (e.g. a local Pi-hole for IoT), while upstream handles the rest.
Then reload: sudo systemctl reload headscale (native) or restart the container, and check tailscale status on your nodes — they should show online with their new names.
Verify it worked//
The non-negotiable end-to-end test, done from a phone that is not on any of your home networks:
- Put the phone on cellular data, join the tailnet (
tailscale upwith web auth or a key). - From the phone's shell (Termux) or SSH app:
ping nas.lab.example.com— an internal hostname that only exists in Headscale's MagicDNS and has no public DNS record at all. - Then
ssh [email protected].
If both work, you've proven the mesh and split DNS with zero edge ports open: a name that can't be resolved by any public resolver reached a host behind your NAT over WireGuard. On the control plane side, headscale nodes list should show every node online. If ping works but SSH doesn't, your ACL is filtering port 22 — check step 3's wildcard audit before blaming the mesh.
When it breaks//
- Headscale refuses to start after upgrade → almost certainly the old
randomize_client_port:key still inconfig.yaml. Remove it (move intent torandomizeClientPortin the policy file) and restart; the changelog is explicit that this one hard-fails on purpose. - "Upgrade blocked" / version path error → you skipped a minor (or tried to downgrade). Walk back through each stable release in order, 0.26 → 0.27 → 0.28 → 0.29.x; patch bumps within a minor are free.
- Traffic "just stops" after the upgrade → ACLs still using
*that used to mean all IPs now silently narrow to tailnet ranges. If you relied on*for internet-bound or non-tailnet traffic, swap inautogroup:danger-all(source-only) or explicit CIDRs, then reload and re-test. - Nodes stuck "expired" → nodes upgraded from < 0.28 until the expiry backfill lands; v0.29.3 also fixed tagged nodes getting stuck expired after
tailscale logout. Re-auth them with a fresh key if they don't clear. - Logged source IPs look wrong behind your proxy → new in 0.29:
X-Forwarded-For/True-Client-IPare now ignored by default; settrusted_proxies:to your proxy's CIDR (e.g.127.0.0.1/32) so the headers are honored again (#3268). Before 0.29 those headers were trusted from any client, so if you never set it and your proxy is on a different host, that's why auth requests suddenly log the wrong IP.
Filed under
Sources
8 cited · 1 primary- headscale.net/stableReporting
- github.com/juanfont/headscale/releasesPrimary
- raw.githubusercontent.com/juanfont/headscale/main/CHANGELOG.mdReporting
- headscale.net/stable/setup/install/containerReporting
- headscale.net/stable/ref/configurationReporting
- headscale.net/stable/ref/policyReporting
- headscale.net/stable/setup/upgradeReporting
- headscale.net/stable/usage/getting-startedReporting
Related dispatches
- tutorialsThe Travel Router That Tunnels Home: GL.iNet + WireGuard on Hostile Wi-Fi — 10 SEP 2026
- exploitsGitLab's CVSS-10 File Read Needs No Login — and the Scanners Are Already Knocking (CVE-2026-85706) — 13 SEP 2026
- exploitsJFrog Artifactory: Three Chained Flaws Turn an Unauthenticated Request Into Admin — and a Rust Backdoor Survived the Patch — 13 SEP 2026