Stop Paying for Tailscale: Run Your Own Control Plane with Headscale (ACLs, Routes, MagicDNS)

Tailscale is genuinely one of the best things to happen to private networking in a decade. Zero-config WireGuard mesh, it just works, blah blah — you already know this. The problem is the control plane. Tailscale’s coordination server is proprietary and cloud-hosted. Your node keys, your network topology, your device list — all of it passes through their infrastructure. For a homelab that’s probably fine. For anything even slightly sensitive, it’s a conversation you don’t want to have with your security team.

Enter Headscale — an open-source, self-hosted reimplementation of the Tailscale control plane. Same clients, same WireGuard underneath, your server. It’s not a Tailscale fork or a wrapper; it’s a clean-room implementation of the coordination protocol. You run it, you own it.

This article skips the "what is WireGuard" primer. You’re here because Headscale is running (or you’re about to run it) and you want the three features that actually make a mesh network useful: fine-grained access control with ACLs, subnet routes for reaching non-Tailscale machines, and MagicDNS so you can stop memorizing 100.x.x.x addresses. Let’s get into it.

Prerequisites

  • Headscale installed and running (Docker Compose or binary, doesn’t matter)
  • At least two nodes enrolled in your tailnet
  • headscale CLI access on the server

If you need the initial setup, the official docs cover it. Come back when you have headscale nodes list returning something.

The Config File You Need to Know

Headscale’s main config lives at /etc/headscale/config.yaml. Before touching ACLs or DNS, make sure these core sections look sane:

# /etc/headscale/config.yaml

server_url: https://headscale.yourdomain.com  # publicly reachable URL
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

# Where your ACL policy file lives
acls:
  policy_path: /etc/headscale/acl.hujson

# MagicDNS config lives under dns
dns:
  magic_dns: true
  base_domain: ts.internal          # hostnames resolve as <node>.<user>.ts.internal
  nameservers:
    global:
      - 1.1.1.1
      - 8.8.8.8
  search_domains: []
  override_local_dns: true          # push DNS config to all clients

# IP allocation
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential            # or 'random'

Restart Headscale after every change to this file: systemctl restart headscale or docker compose restart headscale.


ACLs: Locking Down Who Can Talk to What

Out of the box, Headscale runs in "allow all" mode. Every node can reach every other node. That’s fine for a personal homelab where you’re the only user, but the moment you add a second person, a server, or a service you’d rather keep isolated, you need ACLs.

Headscale uses the exact same HuJSON ACL format as Tailscale. HuJSON is JSON with comments and trailing commas — tolerable for humans, parseable by machines.

A Real-World ACL Policy

Here’s a policy that covers a typical self-hoster setup: admin access from anywhere, servers in a dedicated tag that regular users can reach on specific ports, and IoT devices that are strictly isolated:

// /etc/headscale/acl.hujson
{
  // Tag owners — who is allowed to apply a tag to a device
  "tagOwners": {
    "tag:server":  ["autogroup:admin"],
    "tag:iot":     ["autogroup:admin"],
    "tag:exit":    ["autogroup:admin"],
  },

  // Named groups make rules readable
  "groups": {
    "group:admins":    ["user1@"],        // your headscale user names
    "group:trusted":   ["user1@", "user2@"],
  },

  // Aliases for IP ranges or tag sets
  "hosts": {
    "home-subnet":  "192.168.1.0/24",
    "servers":      "tag:server",
    "iot-devices":  "tag:iot",
  },

  "acls": [
    // Admins can reach everything, no restrictions
    {
      "action": "accept",
      "src":    ["group:admins"],
      "dst":    ["*:*"],
    },

    // Trusted users can SSH and hit HTTPS on tagged servers
    {
      "action": "accept",
      "src":    ["group:trusted"],
      "dst":    ["servers:22", "servers:443", "servers:80"],
    },

    // All nodes can use exit nodes for internet traffic
    {
      "action": "accept",
      "src":    ["*"],
      "dst":    ["tag:exit:0"],           // port 0 = all ports
    },

    // IoT is completely isolated — no inbound, no outbound to tailnet
    // (absence of a rule = implicit deny)
  ],

  // Allow pings for debugging — optional but strongly recommended
  "acls": [
    {
      "action": "accept",
      "proto":  "icmp",
      "src":    ["*"],
      "dst":    ["*:*"],
    },
  ],
}

Gotcha: HuJSON allows duplicate keys in an object — which means if you accidentally write two "acls" blocks like above, only the last one wins. The example shows the structure; in your real file, merge all ACL rules into a single "acls" array.

Apply it:

headscale acls validate --policy-file /etc/headscale/acl.hujson
headscale acls reload   # or restart the service

Tagging Nodes

Tags are applied to nodes, not users. A tagged node inherits the ACL rules for that tag regardless of which user enrolled it.

# List your nodes to get their IDs
headscale nodes list

# Apply a tag (use the node ID or name)
headscale nodes tag --identifier web-server-01 --tags tag:server
headscale nodes tag --identifier raspi-home    --tags tag:iot

Gotcha: Tags in Headscale don’t auto-propagate to connected clients in real time. Clients re-fetch their policy on a timer (typically a few minutes). For immediate enforcement, have the node run tailscale set or restart the Tailscale daemon on the client.

The Implicit Deny

This trips people up every time: Headscale ACLs are default-deny once you define any rule. The moment your acl.hujson has any content, anything not explicitly permitted is blocked. If you have services suddenly unreachable after enabling ACLs, the policy ate your traffic. Use tailscale ping and tailscale status from the client side to debug connectivity; they’ll tell you if the path exists at the WireGuard level even when the ACL is dropping packets.


Routes: Subnet Routing and Exit Nodes

Routing is where Headscale pulls ahead of a plain WireGuard setup. You can expose entire subnets through a single enrolled node, letting non-Tailscale machines participate in the mesh without installing anything on them.

Subnet Routes

Say you have a home server at 192.168.1.0/24 and you want to reach your NAS, your router admin panel, and your smart home hub from a laptop anywhere in the world. You advertise the subnet from the home server:

# On the machine that can reach 192.168.1.0/24
tailscale up --login-server https://headscale.yourdomain.com \
             --advertise-routes=192.168.1.0/24

That’s the client side. The route is now advertised but not active — Headscale requires admin approval. This is intentional; you don’t want any random node on your network declaring itself a gateway.

# On the Headscale server: list pending routes
headscale routes list

# Output looks like:
# ID | Node          | Prefix          | Advertised | Enabled
# 1  | home-server   | 192.168.1.0/24  | true       | false

# Enable it
headscale routes enable --route 1

Nodes will now route traffic for 192.168.1.0/24 through home-server. Test from your laptop:

ping 192.168.1.1        # your router
curl http://192.168.1.100:8080  # whatever's there

Gotcha: IP forwarding must be enabled on the advertising node, or packets arrive and die:

echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
sysctl -p

Many distros have this off by default. This is the #1 reason subnet routing silently fails.

Gotcha: If you’re advertising a subnet that overlaps with the Tailscale address space (100.64.0.0/10), things get weird. Don’t do that.

Exit Nodes

Exit nodes route all internet traffic from a client through the exit node. Useful for untrusted networks, coffee shops, or when you need your traffic to appear from your home IP.

# On the machine you want to use as an exit node
tailscale up --login-server https://headscale.yourdomain.com \
             --advertise-exit-node

Approve on the server:

headscale routes list
# Find the 0.0.0.0/0 and ::/0 routes for that node
headscale routes enable --route <id-for-0.0.0.0/0>
headscale routes enable --route <id-for-::/0>

Use it from a client:

# Enable exit node usage
tailscale up --exit-node=<exit-node-name-or-ip>

# Or pick it interactively (on macOS/Windows desktop clients)
# Settings → Exit node → select your node

# Disable exit node when done
tailscale up --exit-node=

For the ACL side, make sure your policy includes the rule that allows nodes to use exit nodes — the "dst": ["tag:exit:0"] rule from the ACL example earlier handles this.

High-Availability Routes

Multiple nodes can advertise the same subnet. Headscale will enable all of them, and clients do active/passive failover. If the primary subnet router goes offline, traffic automatically shifts to the backup within ~30 seconds. Set this up with two machines on the same LAN both running:

tailscale up --advertise-routes=192.168.1.0/24

Then enable both routes on the server. No additional config needed — the protocol handles failover automatically.


MagicDNS: Human-Readable Names for Your Mesh

Remembering that your Jellyfin server is at 100.64.0.5 and your Gitea is at 100.64.0.12 gets old fast. MagicDNS pushes DNS configuration to all clients so nodes are reachable by name.

How It Works in Headscale

Headscale sets base_domain in its config. Every enrolled node gets a hostname in the format:

<node-name>.<user-name>.<base_domain>

With base_domain: ts.internal and a user named nikita owning a node called home-server, that node is reachable as home-server.nikita.ts.internal from anywhere on the tailnet.

The DNS is pushed to clients via the coordination protocol. Clients update their system resolver (or run a local stub resolver) to handle these names. No external DNS server required.

Enabling MagicDNS

Make sure your config.yaml has:

dns:
  magic_dns: true
  base_domain: ts.internal
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1

Restart Headscale. Re-authenticate one of your clients (tailscale logout && tailscale up --login-server ...) to pick up the new DNS config, or just wait for the next policy refresh.

Test it:

# From any enrolled node
ping home-server.nikita.ts.internal
curl https://gitea.nikita.ts.internal

# Headscale also registers a short-form alias within the same user namespace
ping home-server   # works if the client is in the same user

Gotcha: override_local_dns: true tells clients to send all DNS through the tailnet resolver, including for external domains. This can break things if your clients are on networks with captive portals or split-horizon DNS. Set it to false if you only want the ts.internal domain handled by Headscale and everything else handled normally.

Custom DNS with Split DNS

You can push specific DNS server assignments per domain. This is powerful for reaching internal corporate or homelab services that have their own DNS:

dns:
  magic_dns: true
  base_domain: ts.internal
  override_local_dns: false         # don't hijack all DNS
  nameservers:
    split:
      "home.lab":      ["192.168.1.53"]   # your internal DNS for home.lab
      "corp.internal": ["10.0.0.1"]
    global:
      - 1.1.1.1                     # fallback for everything else

With this config, service.home.lab goes to your Pi-hole or Unbound at 192.168.1.53, corp.internal names go to your AD DNS, and everything else goes to Cloudflare. Clean separation, no conflicts.

Gotcha: Split DNS only works reliably on Linux clients running systemd-resolved. On macOS, split DNS works but requires the Tailscale client to manage DNS through the macOS system. On Windows, it mostly works. On iOS/Android, it works well. The weakest link is older Linux distros using /etc/resolv.conf directly — those get global nameservers only, split DNS is silently ignored.

Custom Hostnames via Extra DNS Records

Headscale doesn’t have a built-in way to add arbitrary DNS records (unlike Tailscale’s funnel feature), but you can point your clients at a real DNS server (Pi-hole, Unbound, AdGuard Home) that you’ve enrolled in the tailnet, and use split DNS to route the relevant domain to it. That DNS server can have any records you want.

dns:
  nameservers:
    split:
      "home.lab": ["100.64.0.3"]   # 100.64.0.3 is your Pi-hole on the tailnet

Your Pi-hole is now reachable from every enrolled device and handles home.lab resolution. You get custom records, ad blocking, and query logging all in one.


Docker Compose: The Production Setup

If you’re running Headscale in Docker, here’s a solid starting point that maps the ACL file and persists state properly:

# docker-compose.yml
services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale         # config.yaml, acl.hujson
      - headscale-data:/var/lib/headscale
    ports:
      - "8080:8080"                     # coordination server (put nginx/Caddy in front)
      - "9090:9090"                     # metrics (keep internal)
    environment:
      - TZ=Europe/Moscow

  headscale-ui:
    image: ghcr.io/gurucomputing/headscale-ui:latest
    container_name: headscale-ui
    restart: unless-stopped
    ports:
      - "8081:80"
    environment:
      - HEADSCALE_URL=http://headscale:8080

volumes:
  headscale-data:

Mount your config/ directory with both config.yaml and acl.hujson inside it. The headscale-ui container gives you a browser UI for managing nodes and routes without typing CLI commands — useful when you’re adding nodes from a phone.

Gotcha: Never expose port 8080 directly to the internet without TLS. Put Caddy or nginx in front. Caddy is the path of least resistance:

headscale.yourdomain.com {
    reverse_proxy localhost:8080
}

That’s the entire Caddyfile entry. Certificates are automatic.


Debugging Toolkit

When something isn’t working, this is the sequence that catches 90% of issues:

# 1. Check that the node is connected and sees its peers
tailscale status

# 2. Ping a peer directly — this tests the WireGuard path, not the ACL
tailscale ping <node-name>

# 3. Try an actual connection — this goes through the ACL
ping <node-ip>

# If (2) works but (3) doesn't: ACL is blocking
# If (2) fails: WireGuard/NAT traversal issue, not an ACL issue

# 4. Check the route table on the advertising node
ip route show table 52

# 5. Validate your ACL file before reloading
headscale acls validate --policy-file /etc/headscale/acl.hujson

# 6. Watch Headscale logs in real time
journalctl -u headscale -f
# or in Docker:
docker compose logs -f headscale

The tailscale ping vs plain ping distinction is the most useful diagnostic tool you have. WireGuard connectivity and ACL policy are two separate layers, and people waste hours assuming they’re one.


Production Checklist

Before you call your Headscale deployment done:

  • TLS everywhere — the control plane URL must be HTTPS, clients refuse HTTP by default
  • ACL file in version control — your network policy is code, treat it that way
  • headscale acls validate in your CI/CD if you have any
  • Automated backups of /var/lib/headscale — this directory contains your node keys; losing it means re-enrolling everything
  • Prometheus scraping on :9090/metrics — Headscale exposes solid metrics including active peer counts and control plane latency
  • Set noise.private_key_path explicitly in config and back up that key separately
  • Test a fresh node enrollment from scratch before you’re in production — the login URL flow bites people who haven’t done it from a fresh state

Headscale isn’t a drop-in replacement for Tailscale’s full feature set — there’s no Funnel, no Tailscale SSH (yet), no mobile-friendly admin UI out of the box. But for the core use case of "give me a private encrypted mesh I actually own," it’s production-grade and actively maintained. The ACL format is compatible enough that you can often copy a Tailscale policy file directly and it just works.

The three features covered here — ACLs, subnet routes, MagicDNS — cover probably 90% of what homelab operators actually need from a control plane. Get those right and you have a networking foundation that’s genuinely solid.

👁 Views: 112,655 · Unique visitors: 45,393