Security

Tailscale and Headscale: zero-config mesh VPN for self-hosted infrastructure.

February 25, 2026 ยท12 min read ยท47Network Engineering

WireGuard is excellent. Tailscale is WireGuard with a control plane โ€” automatic key exchange, NAT traversal, a mobile app, and ACLs. Headscale is a self-hosted implementation of that control plane for teams who don't want their network topology managed by a third party. This post covers when to use each, how Tailscale's ACL system compares to traditional firewall rules, subnet routing for reaching non-Tailscale hosts, and the Headscale deployment pattern used in 47Network Studio zero-trust engagements.

How Tailscale works (briefly)

Tailscale wraps WireGuard in a coordination layer. Each device gets a WireGuard key pair, and the Tailscale control server (or Headscale, if self-hosted) acts as a key directory: "here are the current public keys and endpoints for all nodes in your tailnet." Direct peer-to-peer WireGuard connections are established where NAT allows; traffic falls back through DERP relay servers when direct connections aren't possible. The result: a flat mesh network where every device can reach every other device at a stable 100.x.x.x IP (or MagicDNS hostname like server-01.tail12345.ts.net), regardless of which ISP or datacenter each device is in.

Tailscale vs Headscale: when to self-host the control plane

Tailscale (the SaaS product) is the right choice for most teams. It's easy, the free tier supports 100 devices, and the control plane being managed by Tailscale Inc. is fine for most threat models. The reasons to run Headscale instead:

  • Data sovereignty requirements. Some clients (fintech, healthcare, legal) require that nothing about their network topology โ€” not even device names โ€” passes through a third-party control plane.
  • No dependency on external SaaS. If Tailscale's control servers have an outage, existing WireGuard connections continue but no new connections can be established. With Headscale on your own infrastructure, you control this.
  • Custom identity integration. Headscale supports OIDC authentication, so you can tie device registration directly to your existing Keycloak/47ID deployment.

Headscale deployment

# Install Headscale on a dedicated management server
wget https://github.com/juanfont/headscale/releases/download/v0.23.0/headscale_0.23.0_linux_amd64.deb
dpkg -i headscale_0.23.0_linux_amd64.deb

# /etc/headscale/config.yaml โ€” key settings
server_url: https://headscale.internal.example.com:443
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090   # Prometheus scrape endpoint

# OIDC integration with Keycloak
oidc:
  issuer: https://sso.internal.example.com/realms/company
  client_id: headscale
  client_secret: your-oidc-client-secret
  scope: ["openid", "profile", "email"]
  # Users are mapped to Headscale users by email claim

# IP address range for the tailnet
ip_prefixes:
  - fd7a:115c:a1e0::/48   # IPv6 (Tailscale-compatible range)
  - 100.64.0.0/10          # IPv4

# DNS magic names
dns_config:
  magic_dns: true
  base_domain: tail.internal.example.com
  nameservers:
    - 10.0.0.1   # Internal resolver

# Start and register a user (namespace)
systemctl enable --now headscale
headscale users create engineering

# Generate a pre-auth key for unattended device registration
headscale preauthkeys create --user engineering --reusable --expiration 24h
# On each device that should join the tailnet
# Install tailscale client but point it at your Headscale server
tailscale up \
  --login-server https://headscale.internal.example.com \
  --authkey your-preauth-key \
  --accept-routes \
  --accept-dns

ACLs: network policy as code

Tailscale ACLs define which devices can reach which other devices โ€” this is where the zero-trust model comes from. By default, every device in a tailnet can reach every other device. ACLs let you restrict this to "developers can SSH to servers, servers can talk to the database, nobody else can":

// acls.json โ€” Headscale ACL policy
{
  "groups": {
    "group:engineering": ["user:alice@example.com", "user:bob@example.com"],
    "group:servers":     ["tag:server"],
    "group:databases":   ["tag:database"]
  },
  "tagOwners": {
    "tag:server":   ["group:engineering"],
    "tag:database": ["group:engineering"]
  },
  "acls": [
    // Engineers can SSH to any server
    {
      "action": "accept",
      "src":    ["group:engineering"],
      "dst":    ["group:servers:22"]
    },
    // Servers can connect to databases on Postgres port
    {
      "action": "accept",
      "src":    ["group:servers"],
      "dst":    ["group:databases:5432"]
    },
    // Monitoring host can scrape all nodes on Prometheus port
    {
      "action": "accept",
      "src":    ["tag:monitoring"],
      "dst":    ["*:9100"]
    }
    // Everything else is denied by default
  ]
}

Subnet routing: reaching non-Tailscale hosts

Not every device on your network can run the Tailscale client โ€” network switches, printers, legacy servers, IoT devices. A subnet router advertises an entire network range into the tailnet so any Tailscale device can reach non-Tailscale hosts:

# On the router host (must be in the same LAN as the non-Tailscale devices)
# Enable IP forwarding
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
sysctl -p

# Advertise the 10.0.1.0/24 subnet into the tailnet
tailscale up --advertise-routes=10.0.1.0/24

# On the Headscale server, approve the route
headscale routes list
headscale routes enable --route ROUTE_ID

Exit nodes: routing all traffic through a specific server

A Tailscale exit node routes all internet traffic from a device through that specific node โ€” useful for accessing region-locked resources, giving remote workers a fixed outbound IP for allow-list purposes, or routing sensitive traffic through an audited server:

# On the server that will be the exit node
tailscale up --advertise-exit-node

# Approve the exit node in Headscale
headscale routes list
headscale routes enable --route ROUTE_ID

# On a client device, use the exit node
tailscale up --exit-node=100.64.0.5   # IP of the exit node
# Or by hostname:
tailscale up --exit-node=server-01.tail.internal.example.com

MagicDNS and split DNS for internal services

Tailscale's MagicDNS assigns stable hostnames to every device โ€” server-01.tail.internal.example.com always resolves to that server's Tailscale IP, regardless of where it physically is. Combined with split DNS, you can ensure internal hostnames only resolve inside the tailnet:

# In Headscale config โ€” configure split DNS
dns_config:
  magic_dns: true
  base_domain: tail.internal.example.com
  nameservers:
    - 10.0.0.1      # Internal DNS resolver for internal zones
  restricted_nameservers:
    internal.example.com:
      - 10.0.0.1    # Only use internal resolver for this zone
  # All other queries go to the device's normal resolver

The practical result: vault.internal.example.com resolves only for devices on the tailnet, returning the Vault server's internal IP. The same hostname returns NXDOMAIN or the wrong IP from outside the tailnet โ€” making it impossible to accidentally access internal services from untrusted networks even if you have valid credentials.

Tailscale + Headscale in Studio engagements: for clients who require full control-plane sovereignty, 47Network deploys Headscale on the same management server that runs Vault and Prometheus. The tailnet becomes the management plane โ€” all SSH access, Vault access, and Prometheus scraping happens over Tailscale-encrypted WireGuard tunnels, with ACLs enforcing least-privilege access. No public SSH ports. No exposed Vault API. The fintech zero-trust engagement uses this pattern for all privileged access.


โ† Back to Blog WireGuard Guide โ†’