Skip to main content

Open-source alternatives guide

Self-Host Headscale: Private Tailscale Control Server 2026

Self-host Headscale as a Tailscale control server in 2026. Run your own WireGuard mesh network with Tailscale clients — complete Docker setup with ACLs.

·OSSAlt Team
Share:

TL;DR

Headscale is an open source implementation of the Tailscale control server — BSD 3-Clause, ~22K GitHub stars, written in Go. You run your own coordination server; Tailscale clients connect to it instead of Tailscale's cloud. Result: a private WireGuard mesh network you fully control — same ease-of-use as Tailscale, no data sent to Tailscale's servers. Deploy Headscale on any VPS in 10 minutes.

Key Takeaways

  • Headscale: BSD 3-Clause, ~22K stars, Go — open source Tailscale control server
  • Use Tailscale clients: Headscale is only the control plane; all clients remain standard Tailscale apps
  • WireGuard mesh: All nodes connect directly peer-to-peer (no traffic through your server)
  • Privacy: Your node list, keys, and ACLs stay on your server — not Tailscale's cloud
  • Cost: Free for unlimited devices (vs Tailscale Free: 3 users / 100 devices, paid plans $6+/user/mo)
  • Subnet routing and exit nodes: Full Tailscale features work with Headscale

How Tailscale/Headscale Works

Traditional VPN:   Client → VPN Server → Internet (all traffic through server)

Tailscale mesh:    Client ←→ Control Server (key exchange only)
                   Client ←→ Client (direct WireGuard connection)

The control server (Tailscale's cloud, or your Headscale) only handles:

  • Node registration and authentication
  • Key distribution (WireGuard public keys)
  • ACL policy distribution

Actual traffic flows directly between nodes (peer-to-peer), not through the control server.

Headscale vs Tailscale Free vs ZeroTier

FeatureHeadscaleTailscale FreeTailscale BusinessZeroTier
CostVPS onlyFree (limits)$6/user/monthFree (25 devices)
Max devicesUnlimited100 devicesUnlimited25 (free)
UsersUnlimited3 usersPer planUnlimited
ControlFull (self-hosted)Tailscale's cloudTailscale's cloudZeroTier cloud
iOS/Android appTailscale appTailscale appTailscale appZeroTier app
Exit nodes
Subnet routing
MagicDNSVia DNS configVia DNS
Open sourceClient onlyClient only

Part 1: Deploy Headscale

# docker-compose.yml
version: '3.8'

services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    ports:
      - "8080:8080"
      - "9090:9090"     # Metrics (optional)
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    command: headscale serve

Configuration File

# config/config.yaml
server_url: https://headscale.yourdomain.com:443

listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

# Database
db_type: sqlite3
db_path: /var/lib/headscale/db.sqlite

# Private key for the server
private_key_path: /var/lib/headscale/private.key

# Noise protocol (Tailscale's encryption layer)
noise:
  private_key_path: /var/lib/headscale/noise_private.key

# IP address allocation for nodes
ip_prefixes:
  - 100.64.0.0/10    # Standard Tailscale IP range
  - fd7a:115c:a1e0::/48

# DNS
dns_config:
  magic_dns: true
  base_domain: your-tailnet.net   # e.g., nodes get: hostname.your-tailnet.net
  nameservers:
    - 1.1.1.1

# DERP relay servers (Tailscale's relay infrastructure for NAT traversal)
# Use Tailscale's DERP or run your own
derp:
  server:
    enabled: false   # Disable built-in DERP (use Tailscale's public DERP)
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 24h

# Logging
log:
  level: info
  format: text

# ACME (TLS certificate) — if you want Headscale to handle TLS directly
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: admin@yourdomain.com
tls_letsencrypt_hostname: headscale.yourdomain.com
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: TLS-ALPN-01
# Start Headscale:
docker compose up -d

# Check it's running:
docker exec headscale headscale version

Part 2: HTTPS with Caddy

For Headscale behind a reverse proxy (recommended instead of built-in ACME):

headscale.yourdomain.com {
    reverse_proxy localhost:8080

    # gRPC support (required by Tailscale clients):
    transport http {
        versions h2c 2
    }
}

In config.yaml, set:

server_url: https://headscale.yourdomain.com
tls_letsencrypt_hostname: ""   # Leave empty (Caddy handles TLS)

Part 3: Create Users and Register Nodes

Create a User (Headscale namespace)

# Create a user:
docker exec headscale headscale users create alice

# List users:
docker exec headscale headscale users list

Generate Auth Key

# Create a reusable auth key for a user:
docker exec headscale headscale preauthkeys create --user alice --reusable --expiration 24h

# Output:
# Key: 1234567890abcdef...

Register a Node (on the client device)

Install the Tailscale client on any device, then:

# macOS / Linux:
tailscale up --login-server https://headscale.yourdomain.com --authkey 1234567890abcdef

# Or register interactively (browser-based):
tailscale up --login-server https://headscale.yourdomain.com
# Open the shown URL in browser → log in → Headscale registers the node

On Headscale server, approve the registration:

# List pending nodes:
docker exec headscale headscale nodes list

# Approve a specific node (if not using auth keys):
docker exec headscale headscale nodes register --user alice --key nodekey:abc123

View Connected Nodes

docker exec headscale headscale nodes list

# ID  Hostname          User   IP Addresses             Last Seen
# 1   alice-macbook     alice  100.64.0.1, fd7a:...     2026-03-09 10:00:00
# 2   home-server       alice  100.64.0.2, fd7a:...     2026-03-09 10:00:01
# 3   work-laptop       alice  100.64.0.3, fd7a:...     Online

Test Connectivity

From any registered node:

# Ping another node by Tailscale IP:
ping 100.64.0.2

# Or by MagicDNS hostname:
ping home-server.your-tailnet.net

# SSH directly:
ssh 100.64.0.2

Part 4: Access Control Lists (ACLs)

Headscale supports Tailscale's ACL format. ACLs control which nodes can communicate:

# config/acls.hujson
{
  "groups": {
    "group:admin": ["alice"],
    "group:devs": ["alice", "bob"],
    "group:readonly": ["charlie"]
  },

  "acls": [
    // Admins can access everything:
    {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]},

    // Devs can access dev servers:
    {"action": "accept", "src": ["group:devs"], "dst": ["tag:dev-server:*"]},

    // Everyone can ping:
    {"action": "accept", "src": ["*"], "dst": ["*:icmp"]},

    // Deny everything else:
    {"action": "deny", "src": ["*"], "dst": ["*:*"]}
  ],

  "tagOwners": {
    "tag:dev-server": ["group:admin"]
  }
}
# Apply ACL policy:
docker exec headscale headscale policy set --policy-file /etc/headscale/acls.hujson

Part 5: Subnet Routing

Route your home network (192.168.1.0/24) through a Headscale node:

# On the node that's on the subnet you want to route:
tailscale up --login-server https://headscale.yourdomain.com \
  --advertise-routes=192.168.1.0/24

# On the Headscale server, approve the route:
docker exec headscale headscale routes list
docker exec headscale headscale routes enable --route 1   # Route ID from list

# Now all Headscale nodes can reach 192.168.1.0/24 via this node

Use cases:

  • Access your home NAS (192.168.1.x) from anywhere
  • Route office network to remote workers
  • Self-hosted services on internal LAN accessible from outside

Part 6: Exit Nodes

Route all internet traffic through a Headscale node:

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

# Approve on Headscale server:
docker exec headscale headscale routes enable --route 2   # exit route ID

# On client — use this node as internet exit:
tailscale up --exit-node=100.64.0.5    # Exit node's Tailscale IP

Useful for:

  • Route traffic through a specific datacenter
  • Bypass geo-restrictions
  • Privacy when on untrusted WiFi

Part 7: Mobile Clients

iOS and Android use the standard Tailscale app with a custom control server:

iOS:

  1. Install Tailscale from App Store
  2. Open → Settings (top right) → Accounts → Login with custom server
  3. Server URL: https://headscale.yourdomain.com
  4. Login → opens Safari → register node

Android:

  1. Install Tailscale
  2. Tap the three-dot menu → Use a different server
  3. Enter your Headscale URL

Maintenance

# Update Headscale:
docker compose pull
docker compose up -d

# Backup (SQLite database + config):
cp ./data/db.sqlite ./data/db.sqlite.bak
tar -czf headscale-backup-$(date +%Y%m%d).tar.gz ./config ./data

# Revoke a node:
docker exec headscale headscale nodes delete --identifier 3

# Expire auth keys:
docker exec headscale headscale preauthkeys list --user alice

# Remove expired nodes:
docker exec headscale headscale nodes expire --identifier 5

# Check node status:
docker exec headscale headscale nodes list

Resource Usage

Headscale is extremely lightweight:

Idle RAM:     ~30MB (just the Go binary)
Active nodes: +~1MB per node
CPU:          Near zero between key exchanges
Storage:      ~1MB SQLite for hundreds of nodes

Headscale can comfortably run on the smallest VPS alongside many other services.

Why Self-Host Headscale

Tailscale's free plan allows 3 users and up to 100 devices, which works for a solo homelab. The moment you need a fourth user — a family member, a collaborator, a contractor — you're pushed to the Personal Pro plan at $6/user/month or the Teams plan at $6/user/month with a 6-user minimum. Six users on the Teams plan costs $432/year. Headscale eliminates the per-user cost entirely: you pay only for the VPS, and Headscale is genuinely lightweight — the Go binary idles at around 30MB of RAM and uses near-zero CPU between key exchanges.

The control plane ownership is the more compelling argument for many self-hosters. Tailscale's network coordination server knows which devices are on your network, their IP addresses, hostnames, and when they were last seen. That's a detailed map of your infrastructure. With Headscale, that information stays on your server under your control. Tailscale's privacy policy is reasonable, but for organizations handling sensitive data or operating under compliance requirements, running your own coordination plane is sometimes a requirement rather than a preference. Financial services firms, healthcare organizations, and government contractors in particular may have policies prohibiting use of third-party network coordination services regardless of privacy guarantees.

Unlimited devices with no per-device pricing is the third advantage. IoT devices, development VMs, CI/CD build agents, home servers — each one is a device on a Tailscale network. Headscale imposes no device limits. You can register hundreds of nodes without worrying about tier upgrades. Build server fleets, development environments, and home automation devices can all join the same mesh network without per-node cost implications.

The WireGuard mesh model means traffic doesn't flow through your Headscale server — it only handles key exchange and coordination. Even if your Headscale server has limited bandwidth, the actual data flows directly between nodes peer-to-peer. A tiny, cheap VPS can coordinate a mesh network carrying gigabits of traffic.

When NOT to self-host Headscale. Headscale does not support Tailscale's enterprise features: MagicDNS (without additional DNS configuration), Taildrop (file sharing), Mullvad VPN exit nodes, or SSO with identity providers. The setup process requires more manual work — there's no slick web UI for users to self-register. If you're a non-technical household and Tailscale's free tier covers your devices, stay on Tailscale. Headscale rewards operators comfortable with the command line.

Prerequisites

Headscale's resource requirements are minimal. The Go binary uses around 30MB RAM at idle and barely touches CPU between client registrations. A Hetzner CX11 (2 vCPU, 2GB RAM) at €3.29/month is genuinely sufficient, even for 50+ connected nodes. The CX22 (4GB RAM) at €4.50/month gives you comfortable headroom to run Headscale alongside other services on the same VPS. For network topology reasons, pick a datacenter region close to most of your devices to minimize coordination latency. See the VPS comparison guide for provider options across different regions.

Docker Engine 24+ and Docker Compose v2 are required. The Headscale container needs a persistent ./config and ./data directory. Create them before first run:

mkdir -p ./config ./data

The Headscale server needs to be publicly accessible on port 443 (via Caddy). Tailscale clients use gRPC for coordination, which requires HTTP/2 support — the Caddy configuration in Part 2 includes transport http { versions h2c 2 } for this reason.

DNS: create an A record for headscale.yourdomain.com pointing to your VPS IP. The server_url in config.yaml must match this exactly — Tailscale clients embed the control server URL in their certificate validation and will fail to connect if there's a mismatch.

Production Security Hardening

Headscale coordinates encryption keys for your entire private network. Securing the server is critical.

UFW firewall. Allow only what's needed:

ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Headscale's metrics port 9090 should only be accessible from trusted IPs if you enable it. Port 8080 (Headscale's internal port) should never be exposed publicly — Caddy proxies it on 443.

Fail2ban for SSH. Headscale itself doesn't have a login form to protect, but the host SSH service does:

apt install fail2ban -y

Create /etc/fail2ban/jail.local:

[DEFAULT]
bantime  = 2h
findtime = 10m
maxretry = 3

[sshd]
enabled = true
systemctl restart fail2ban

SSH key-only authentication. Disable password logins:

# /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
systemctl restart sshd

Secrets management. Headscale generates its own private keys on first run (private.key and noise_private.key in ./data). These are the most critical files on your server — if an attacker obtains them, they can impersonate your coordination server. Restrict permissions:

chmod 700 ./data
chmod 600 ./data/*.key

Back up these keys separately from your regular backups — losing them requires re-registering all nodes. See the automated backup guide for encrypted off-server backups using Restic.

Automatic OS updates. Keep the host patched:

apt install unattended-upgrades -y
dpkg-reconfigure --priority=low unattended-upgrades

For a complete hardening checklist, see the self-hosting security checklist.

Troubleshooting Common Issues

Tailscale client shows "control server unreachable." The most common cause is that Caddy's HTTP/2 transport isn't configured correctly. Tailscale clients use gRPC (HTTP/2) for coordination. Without the transport http { versions h2c 2 } setting in the Caddyfile, gRPC connections are downgraded to HTTP/1.1 and fail. Verify your Caddyfile exactly matches the configuration in Part 2.

"Invalid server URL" when registering a node. The server_url in config.yaml must match the URL used in the tailscale up --login-server command. If you've set server_url: https://headscale.yourdomain.com but are passing --login-server http://headscale.yourdomain.com (no s), the client rejects the server. Always use https://.

Node registered but shows "offline" immediately. Run docker exec headscale headscale nodes list to check the node's last-seen timestamp. If it registered but immediately went offline, the WireGuard handshake may be failing due to a NAT traversal issue. Headscale relies on DERP relay servers for NAT traversal when direct connections aren't possible. Confirm derp.urls in config.yaml points to Tailscale's DERP map URL and that your server can reach it outbound.

"preauthkey already used" error. Non-reusable pre-auth keys can only be used once. Create a reusable key with --reusable flag: docker exec headscale headscale preauthkeys create --user alice --reusable --expiration 24h. Or create a fresh key if you need a one-time registration.

iOS Tailscale app won't connect to Headscale. Apple's App Transport Security requires HTTPS with a valid certificate. Ensure Caddy has successfully obtained a Let's Encrypt certificate for your domain before attempting the iOS connection. In the Tailscale iOS app, the custom server URL must be entered under Settings → Accounts → Sign In → Use a different server, not in the main server field.

Headscale container exits with "config file not found." The ./config directory must exist and contain config.yaml before starting the container. Create the config directory and copy the sample config before running docker compose up -d. If ./config is empty, Headscale exits immediately.

Nodes connected but can't reach each other. When direct WireGuard connections aren't possible (both nodes behind NAT without port forwarding), traffic is relayed through DERP servers. If DERP relay is not configured or Tailscale's DERP map URL is unreachable from your Headscale server, nodes will appear connected (key exchange succeeded) but traffic between them will be blocked. Verify outbound connectivity to controlplane.tailscale.com from your Headscale container. Running your own DERP server is an option for fully air-gapped setups but adds operational complexity.

ACL policy not taking effect. After applying a new ACL policy file with headscale policy set, connected nodes need to re-fetch their policy. This happens automatically on the next coordination poll (typically within 60 seconds). If the new policy isn't enforced after a few minutes, trigger a manual policy refresh by running tailscale up on each client node. Check docker compose logs headscale for any policy parse errors that might indicate a syntax issue in your HuJSON file.

MagicDNS hostnames not resolving. Headscale supports a magic_dns: true setting and a base_domain in the DNS config, but MagicDNS resolution requires each Tailscale client to be configured to use the Headscale-provided DNS. Run tailscale status on a connected node to see whether DNS is configured. If hostnames like home-server.your-tailnet.net don't resolve, check that the nameservers list in your Headscale config is populated and that the client's DNS settings show the Headscale-assigned resolver.

Node shows "expired" in the nodes list. Tailscale nodes have a key expiry period. By default, Headscale sets node keys to expire after 180 days. When a node's key expires, it shows as expired and loses connectivity until re-authenticated. To re-authenticate, run tailscale up --login-server https://headscale.yourdomain.com on the expired node. To disable key expiry for specific nodes (useful for servers that can't have interactive re-auth), run docker exec headscale headscale nodes expire --identifier 0 --expiration 0 with a zero expiration. For production nodes (servers, home NAS systems), disabling key expiry is standard practice since interactive re-authentication isn't feasible.

See all open source VPN and networking tools at OSSAlt.com/categories/networking.

The SaaS-to-Self-Hosted Migration Guide (Free PDF)

Step-by-step: infrastructure setup, data migration, backups, and security for 15+ common SaaS replacements. Used by 300+ developers.

Join 300+ self-hosters. Unsubscribe in one click.