Skip to content
Go back

Headscale: Self-Host Your Own Tailscale Control Plane

By SumGuy 10 min read
Headscale: Self-Host Your Own Tailscale Control Plane

You Like Tailscale But You Don’t Trust Their Servers — Fair

Tailscale is, genuinely, one of the best pieces of networking software of the last decade. You install it, you’re done. Your laptop at a coffee shop can reach your home server’s private IP like they’re on the same LAN. MagicDNS, ACLs, subnet routes, exit nodes — it just works. There’s a reason engineers who’ve hand-rolled WireGuard configs say “oh, this is what it was supposed to feel like.”

The catch is buried in the architecture. Every Tailscale node registers with a coordination server. That server stores your network map — who exists, what keys they have, what ACLs apply. Tailscale’s SaaS control plane is closed source. Your devices’ metadata, network topology, and node relationships live in someone else’s database. The free tier caps you at 3 users. And if you work in a context where “we use a third-party coordination server for our internal network” makes the security team reach for antacids, you’ve hit the wall.

Enter Headscale: the open-source reimplementation of the Tailscale control plane. You run it. Your devices still use the official Tailscale client — just pointed at your URL instead of login.tailscale.com. Same WireGuard magic. Same mesh. Your data.

Let’s deploy it.


What Headscale Actually Does (And Doesn’t Do)

Headscale replaces the coordination server only. The data plane — the encrypted WireGuard tunnels between your nodes — never changes. Your laptop still talks directly to your server using the same DERP relay infrastructure Tailscale built. Headscale doesn’t proxy your traffic, it just manages the key exchange and network map.

What you get:

What you don’t get (yet):

For 90% of self-hosting use cases: doesn’t matter.


Prerequisites

Headscale needs to be reachable by your nodes. If you’re behind CGNAT at home, a cheap VPS ($4/month) is worth it — run Headscale there, all your nodes phone home to it.


Deploying Headscale with Docker Compose

Create a directory for the deployment and the config:

Terminal window
mkdir -p /opt/headscale/{config,data}
cd /opt/headscale

The config file is where most of the setup lives. Create a minimal working one:

config.yaml
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
ip_prefixes:
- fd7a:115c:a1e0::/48
- 100.64.0.0/10
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: true
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
acls_policy_path: /etc/headscale/acl.json
dns:
magic_dns: true
base_domain: yourdomain.ts.net
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
search_domains: []
extra_records: []
log:
level: info
tls_letsencrypt_hostname: headscale.example.com
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"

A few things worth pointing out: base_domain becomes the suffix for your MagicDNS hostnames. It doesn’t have to be a real domain you own — Headscale uses it internally. ip_prefixes controls the IP ranges assigned to nodes; the defaults are fine. We’re using SQLite because unless you’re running a serious multi-admin shop, it’s more than adequate.

Now the Compose file:

docker-compose.yml
services:
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
ports:
- "80:8080"
- "443:8080"
- "9090:9090"
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
command: serve
headscale-ui:
image: goodieshq/headscale-admin:latest
container_name: headscale-ui
restart: unless-stopped
ports:
- "8081:80"
environment:
- HEADSCALE_URL=https://headscale.example.com

The headscale-admin image is a community-built web UI. It’s not official, but it’s genuinely useful for visualizing your nodes without memorizing CLI commands. Don’t expose port 8081 publicly — reverse proxy it behind auth if you want it accessible remotely.

Note on TLS: The config above uses Let’s Encrypt via HTTP-01 challenge, which means Headscale needs to answer on port 80 directly. If you’re already running a reverse proxy (Caddy, Nginx, Traefik), you’ll want to either let Headscale handle TLS itself (works cleanly if it owns those ports) or configure tls_cert_path / tls_key_path and have your proxy terminate TLS and forward to port 8080. Both work. The Let’s Encrypt path is the least-config option if you have a clean server.

Fire it up:

Terminal window
docker compose up -d
docker compose logs -f headscale

You should see Headscale start, attempt ACME cert provisioning, and begin listening. Give it 30 seconds. Check https://headscale.example.com/health — you want {"status":"pass"}.


Creating Your First User and Registering a Node

Headscale organizes devices into “users” (previously called namespaces). Think of them as logical groupings — you’d create one per person, or per environment if you’re organizing by role.

Terminal window
# Create a user
docker exec headscale headscale users create alice
# List users
docker exec headscale headscale users list

Now on the machine you want to connect — your laptop, say — run the Tailscale client but point it at your Headscale instance:

Terminal window
sudo tailscale up \
--login-server=https://headscale.example.com \
--accept-routes \
--accept-dns

Tailscale will print a URL: https://headscale.example.com/register/nodekey:...

Copy that URL, then on your server:

Terminal window
# Register the node to the alice user
docker exec headscale headscale nodes register \
--user alice \
--key nodekey:PASTE_THE_KEY_HERE

That’s it. The node is registered. Check it:

Terminal window
docker exec headscale headscale nodes list

Repeat for every machine. Add a second node, register it to the same user or a different one, and they’ll be able to reach each other via their Tailscale IPs.


ACLs: Defining Who Can Talk to What

Without an ACL file, all nodes can reach all other nodes. That’s fine for a two-device personal setup. For anything more structured, you want rules.

Create the policy file your config.yaml is pointing at:

acl.json
{
"groups": {
"group:admins": ["alice"],
"group:homelab": ["alice", "bob"]
},
"hosts": {
"nas": "100.64.0.3",
"pihole": "100.64.0.4"
},
"acls": [
{
"action": "accept",
"src": ["group:admins"],
"dst": ["*:*"]
},
{
"action": "accept",
"src": ["group:homelab"],
"dst": ["nas:443,22", "pihole:53"]
}
]
}

Admins can reach everything. The homelab group can hit the NAS on HTTPS and SSH, and the Pi-hole on DNS. Everyone else gets nothing by default. This is the same HuJSON-ish ACL syntax Tailscale uses — most examples you find for Tailscale ACLs will work with minor formatting adjustments.

Reload the policy without restarting:

Terminal window
docker exec headscale headscale policy set --file /etc/headscale/acl.json

Exit Nodes and Subnet Routes

Exit node — route all traffic from a node through another node. Useful when you want one machine (your home server, a VPS) to act as a VPN exit point.

On the machine that will be the exit node:

Terminal window
sudo tailscale up \
--login-server=https://headscale.example.com \
--advertise-exit-node

On the Headscale server, approve it:

Terminal window
docker exec headscale headscale routes list
docker exec headscale headscale routes enable --route <ROUTE_ID>

On any client that wants to use it:

Terminal window
sudo tailscale up \
--login-server=https://headscale.example.com \
--exit-node=<EXIT_NODE_IP>

Subnet routes work the same way but instead of routing all traffic, you expose a LAN segment. Say your home server is at 192.168.1.5 and you want your phone to reach your whole home network:

Terminal window
# On the home server
sudo tailscale up \
--login-server=https://headscale.example.com \
--advertise-routes=192.168.1.0/24

Approve it on Headscale:

Terminal window
docker exec headscale headscale routes enable --route <ROUTE_ID>

Now your phone — registered to Headscale but sitting anywhere on the internet — can reach 192.168.1.x addresses directly. This is the killer feature for home lab access. No port-forwarding pyramid of doom, no dynamic DNS hacks. Just “your whole LAN, accessible from anywhere.”


MagicDNS

You set magic_dns: true in the config. Now every node gets a hostname: machinename.yourdomain.ts.net. To reach your NAS from your laptop, you can do ssh nas.yourdomain.ts.net instead of remembering Tailscale IPs.

The Tailscale client handles the DNS resolution automatically when it registers. Make sure your nodes have --accept-dns set in their tailscale up command (it’s shown in the examples above).

To add custom DNS records — say, you want homeassistant.yourdomain.ts.net to resolve to a specific IP — use extra_records in the config:

config.yaml
dns:
extra_records:
- name: homeassistant.yourdomain.ts.net
type: A
value: 192.168.1.50

Reload Headscale after config changes:

Terminal window
docker compose restart headscale

OIDC: Let Your SSO Handle Logins

If you’re already running Authentik, Authelia, Keycloak, or any OIDC provider, you can hook it up so users authenticate through SSO instead of manual headscale nodes register commands. They browse to your Headscale URL, hit your SSO, and their node gets auto-registered.

Add to config.yaml:

config.yaml
oidc:
only_start_if_oidc_is_available: true
issuer: https://auth.example.com/application/o/headscale/
client_id: headscale
client_secret: your-client-secret
scope:
- openid
- profile
- email
strip_email_domain: true

Users then run:

Terminal window
sudo tailscale up --login-server=https://headscale.example.com

Tailscale opens a browser URL, they log in through your SSO, node is registered and assigned to a user matching their email. Slick. Especially useful when you’re onboarding family members who would never remember a registration command.


Headscale vs. Tailscale: The Honest Take

Tailscale’s free tier covers 1 admin, up to 3 users, and 100 devices. For most single-person homelabs, that’s actually fine. You get the official polished experience, automatic updates to new features, and you don’t need to maintain a public-facing server.

Use Headscale when:

Stick with Tailscale when:

The deployment we walked through here — Docker Compose, Let’s Encrypt, SQLite — will run happily on a $4 VPS and consume maybe 30 MB of RAM. Maintenance is occasional docker compose pull && docker compose up -d when new releases drop. It’s not a burden.

What you’re trading for that simplicity is Tailscale’s DERP relay network and the upstream polish. Headscale uses Tailscale’s own DERP servers by default (this is allowed by their terms for non-commercial self-hosted use), so connectivity is actually excellent — your traffic still benefits from their relay infrastructure, you’re just using a different coordination server.

The real question isn’t technical. It’s philosophical. Do you care who holds the keys to your network map? If yes, Headscale is your answer. If the SaaS convenience outweighs the data sovereignty concern, Tailscale’s free tier is genuinely excellent software.

Either way, the WireGuard mesh is waiting. Stop forwarding ports.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
mergerfs + SnapRAID: The Poor Man's Unraid
Next Post
Object Storage on a Pi: SeaweedFS Cluster Walkthrough

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts