Headscale: Self-Hosted Tailscale Control Server - 夜莺博客

Headscale: Self-Hosted Tailscale Control Server

Tailscale's clients are excellent — WireGuard-based, NAT-traversing, zero-config — but they normally depend on a hosted coordination server. Headscale is an open-source, self-hosted implementation of that coordination service, which means you keep the clients and the protocol while owning the control plane. That matters for air-gapped labs, privacy-sensitive deployments, and anyone who wants Tailnet policy to live in Git rather than a SaaS console. This guide covers installation, node registration, ACL policy, subnet routing and the operational limits you should know before committing.

Architecture: what Headscale does and does not do

Headscale (control plane)
  - authenticates nodes, distributes public keys and routes
  - never sees your payload traffic
Data plane
  - WireGuard between nodes, direct when NAT traversal succeeds
  - DERP relay only as a fallback for hard NAT

Because payload traffic never traverses the control server, a self-hosted Headscale can run on a small VPS — the bandwidth cost is the DERP relay only, if you run one. If you prefer to stay entirely on stock wireguard configurations, the manual equivalent is described in WireGuard site-to-site; for a comparison with the managed service, see Tailscale mesh operations.

Install

# docker compose is the least painful path
mkdir -p /opt/headscale && cd /opt/headscale
# docker-compose.yml (excerpt)
services:
  headscale:
    image: headscale/headscale:latest
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      - "8080:8080"
      - "9090:9090"
    command: serve

# initialise config + database, then create the first user
docker compose run --rm headscale users create ops
docker compose up -d
server_url: https://vpn.example.com          # must match the client-facing URL
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
dns:
  magic_dns: true
  base_domain: ts.example.com
derp:
  urls: [ "https://controlplane.tailscale.com/derpmap/default" ]

server_url must be exactly the URL clients use, including scheme and port if non-standard. A mismatch produces TLS errors and a client that registers but never reconnects.

Register nodes

# non-interactive: pre-auth key
docker compose exec headscale headscale preauthkeys create --user ops --reusable --expiration 24h

# on the client
tailscale up --login-server https://vpn.example.com --authkey  --accept-routes

# interactive alternative
tailscale up --login-server https://vpn.example.com
docker compose exec headscale headscale nodes register --user ops --key 

# inventory
headscale nodes list
headscale users list
headscale nodes expire -i         # revoke a node

ACL policy in Git

# /etc/headscale/policy.hujson
{
  "groups": {
    "group:ops":  ["alice@example.com", "bob@example.com"],
    "group:devs": ["carol@example.com"]
  },
  "tagOwners": { "tag:server": ["group:ops"] },
  "acls": [
    { "action": "accept", "src": ["group:ops"],  "dst": ["*:*"] },
    { "action": "accept", "src": ["group:devs"], "dst": ["tag:server:22,443,5432"] },
    { "action": "accept", "src": ["group:devs"], "dst": ["group:devs:*"] }
  ],
  "ssh": [
    { "action": "accept", "src": ["group:ops"], "dst": ["tag:server"],
      "users": ["root", "autogroup:nonroot"] }
  ]
}

Default deny applies: traffic not matched by an ACL is dropped. Validate before applying — a malformed policy can lock every node out of every service, so test with a staging tailnet and keep a documented rollback.

Subnet router and exit node

# on the gateway host: forward the LAN behind it
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf && sysctl -p
tailscale up --login-server https://vpn.example.com \
  --advertise-routes=192.168.50.0/24 --advertise-exit-node --accept-routes

# approve on the server
headscale routes list
headscale routes enable -r 

# clients then opt in
tailscale up --accept-routes          # use subnets
tailscale set --exit-node=      # route all traffic

Advertised routes stay pending until explicitly enabled — a step people miss after rebuilding a router, and the reason "the subnet used to work" stories start.

Operations and limits

Backup:      the sqlite database + noise key + config; test restore
Metrics:     prometheus endpoint on :9090
DERP:        host your own for latency control; otherwise public DERP works
Limits:      single tailnet, narrower feature scope than the SaaS control plane
             no built-in SSO beyond OIDC (configure it explicitly)
Upgrades:    headscale is evolving fast; read the release notes, back up first

The most common operational mistake is treating Headscale as a drop-in for the hosted service and then discovering a missing feature mid-migration; scope that first. For pure point-to-point tunnels, plain WireGuard remains simpler (see OpenVPN for the IPsec-adjacent comparison), and for a mesh without a coordination server, ZeroTier CLI operations is the alternative.

FAQ

Q: Does Headscale see my traffic? No — it distributes keys and routes. Payload traffic is WireGuard between peers, or relayed through DERP if direct connection fails.
Q: Can iOS and Android clients connect? Yes, by pointing the official app at your Headscale URL.
Q: Should I run my own DERP server? Only if latency or data-path policy requires it; the relay carries little traffic in healthy networks.

原文链接:https://docs.headscale.org/