Self-hosting Tailscale with Headscale
My home connection is behind CGNAT. My ISP assigns a shared “public” IP across hundreds of customers, so there’s no inbound path from the internet. No port forwarding, no reachable public endpoint.
Tailscale solves this by building a WireGuard mesh between all your devices, handling NAT traversal automatically and falling back to an encrypted relay when direct connections fail. It works well. The catch is that Tailscale’s coordination server - the piece that manages device registration, authentication, and key exchange - is their hosted service. You’re handing your network topology to a third party.
Headscale is the self-hosted alternative. It reimplements Tailscale’s coordination server as an open-source project you run yourself. The WireGuard tunneling, NAT traversal, and relay fallback still come from the official Tailscale client software. Headscale replaces only the coordination layer - the control plane.
Worth being clear about this upfront: Headscale is not a fork of Tailscale. You still install the official Tailscale client on your devices. You just point those clients at your own coordination server instead of Tailscale’s.
Deployment
I ran Headscale as a Docker container inside a Proxmox LXC (privileged), using the same Docker-in-LXC pattern as the monitoring stack and UniFi controller.
docker-compose.yml:
services:
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- '8080:8080'
- '9090:9090'
command: serve
Traefik handles TLS and exposes Headscale at https://headscale.example.net. Port 8080 is the coordination API - the only port proxied. Port 9090 is Prometheus metrics, kept internal.
For the database I chose SQLite over PostgreSQL. At homelab scale with a handful of devices, SQLite is more than adequate and keeps the stack simpler.
The config deprecation problem
Headscale’s configuration format has changed between versions, and the docs don’t always reflect the current schema. Running an outdated config doesn’t crash the server - it logs warnings and continues, which made me think everything was fine when it wasn’t.
The deprecated keys I hit:
ip_prefixes → prefixes
The address space config moved to a nested structure:
# Before
ip_prefixes:
- fd7a:115c:a1e0::/48
- 100.64.0.0/10
# After
prefixes:
v6: fd7a:115c:a1e0::/48
v4: 100.64.0.0/10
db_type / db_path → nested database block
# Before
db_type: sqlite3
db_path: /var/lib/headscale/db.sqlite
# After
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
dns_config → dns and acl_policy_path → policy.path
After fixing these, check startup logs for any remaining warnings:
docker logs headscale | grep -i deprecat
The server starts with deprecated keys, but depending on which ones, behavior can be wrong in non-obvious ways.
Users and pre-auth keys
Headscale organizes devices under users (previously called namespaces - --namespace is deprecated, use --user).
Create a user:
docker exec headscale headscale users create homelab
Get the numeric user ID:
docker exec headscale headscale users list
Generate a pre-auth key:
docker exec headscale headscale preauthkeys create -u <user-id> --reusable --expiration 90d
The -u flag takes a numeric user ID, not the name. On each device, connect with:
tailscale up --login-server=https://headscale.example.net --authkey=<key>
iOS: interactive registration only
iOS and tvOS Tailscale clients can’t use pre-auth keys with a custom login server - a known limitation in the upstream iOS app. The workaround is interactive registration: the iOS client generates a URL, you open it in a browser, then register the device via the Headscale CLI:
docker exec headscale headscale nodes register --user homelab --key <node-key>
It works, but it’s a multi-step flow compared to the one-liner on Linux or macOS. I’m covering the iOS and tvOS setup in a separate post.
Subnet router
Tailscale devices outside my home can reach the Headscale coordination server, but not the homelab LAN - that’s hidden behind CGNAT. A subnet router bridges them.
One of the homelab LXC containers runs Tailscale and advertises the homelab subnet:
tailscale up \
--login-server=https://headscale.example.net \
--authkey=<key> \
--advertise-routes=192.168.x.x/24
Then approve the route in Headscale:
docker exec headscale headscale routes list
docker exec headscale headscale routes enable -r <route-id>
With the route active, any Tailscale device can reach homelab services by their LAN address. No public exposure, no port forwarding.
DERP and CGNAT
When a device outside my home tries to connect to the homelab subnet router, CGNAT blocks the direct WireGuard tunnel. Tailscale falls back to DERP (Designated Encrypted Relay for Packets) - encrypted relay servers that both endpoints connect through when direct P2P isn’t possible. Traffic passes through DERP but is still end-to-end WireGuard encrypted; the relay only sees ciphertext.
With Headscale, you control the coordination plane but DERP servers are Tailscale’s by default. You can self-host DERP if you want to keep everything in-house, but for a homelab this felt like unnecessary complexity. The data is encrypted regardless.
Lessons
Headscale replaces the control plane, not the transport. The WireGuard tunnels, NAT traversal, and DERP relay all still come from Tailscale’s client software. Understanding this distinction is what made the architecture click for me.
Check for deprecated config keys. Headscale doesn’t refuse to start with outdated config - it warns and continues. Those warnings are real: some deprecated paths get silently ignored and you end up with wrong behavior that isn’t obvious until you test something specific.
--user, not --namespace. The CLI changed. Pre-auth key creation takes -u with a numeric ID, not a name. Older documentation uses the old flag; the Headscale help output is authoritative.
SQLite is enough. I spent time considering PostgreSQL. At homelab scale, SQLite was the right call and I’d make it again.
The initial setup for this deployment used the same Docker-in-LXC pattern described in Deploying UniFi Network Controller with Traefik.
Related reading
Deploying UniFi Network Controller with Traefik: Decisions and Troubleshooting
Decision analysis for LXC+Docker vs VM deployment, hybrid networking strategy for UniFi device communication, and troubleshooting firewall and IP configuration issues.
Connecting iPhone and Apple TV to Headscale
iOS and tvOS Tailscale clients can't use pre-auth keys with a custom coordination server. Here's the interactive registration flow, the localhost hostname gotcha, and what to expect from VPN-on-demand.
Replacing Firefly III with Actual Budget
Why I swapped out a finance app I never opened for one I actually use, and how the Docker-in-LXC deployment on Proxmox went.
Ready to Transform Your Career?
Let's work together to unlock your potential and achieve your professional goals.