Self-Host Headscale: Your Own Tailscale Control Server in 15 Minutes
WireGuard is great — until you have to manage keys, configs, and NAT traversal for every device manually. Tailscale solves that, but its control plane lives in someone else's cloud. Headscale gives you the best of both: the Tailscale client experience with a control server you own.
In this guide, you'll deploy Headscale on a VPS, connect your first devices, and understand when to choose it over plain WireGuard or managed Tailscale.
Why Self-Host Headscale?
- Privacy: Your network topology, device names, and metadata never leave your infrastructure
- No device limits: Tailscale's free tier caps you at 100 devices. Headscale has no such ceiling
- Custom ACLs: Full control over who can reach what, defined in a simple YAML policy
- Cost: One VPS serves unlimited users and devices
- Integration: Works with existing Tailscale clients on Linux, macOS, Windows, iOS, and Android
Prerequisites
- A VPS with a public IP (1 vCPU / 512MB RAM is plenty for small networks)
- Docker and Docker Compose installed
- A domain pointed at your VPS (e.g.,
headscale.example.com) - Ports 80/tcp and 443/tcp open (for ACME / reverse proxy)
- UDP 41641 open (WireGuard)
Step 1: Create the Docker Compose File
Create a directory and drop in docker-compose.yml:
version: "3.8"
services:
headscale:
image: headscale/headscale:0.23.0
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- "127.0.0.1:8080:8080" # HTTP — reverse proxy handles TLS
- "41641:41641/udp" # WireGuard
networks:
- headscale-net
networks:
headscale-net:
driver: bridge
Step 2: Configure Headscale
Create config/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: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v6: fd7a:115c:a1e0::/48
v4: 100.64.0.0/10
allocation: sequential
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
log:
format: text
level: info
acl_policy_path: ""
dns:
magic_dns: true
base_domain: example.com
nameservers:
global:
- 1.1.1.1
- 9.9.9.9
search_domains:
- example.com
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
Replace headscale.example.com and example.com with your actual domain.
Step 3: Launch the Stack
mkdir -p config data
docker compose up -d
docker compose logs -f headscale
You should see listening and serving HTTP on: 0.0.0.0:8080.
Step 4: Add a Reverse Proxy (Caddy)
Headscale needs HTTPS for the Tailscale clients to trust it. Caddy handles TLS automatically:
# Add to docker-compose.yml
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
networks:
- headscale-net
volumes:
caddy_data:
Create Caddyfile:
headscale.example.com {
reverse_proxy headscale:8080
}
docker compose up -d
Step 5: Create Your First User
docker exec headscale headscale users create ali
Step 6: Connect a Device
Generate a pre-auth key (one-time or reusable):
docker exec headscale headscale preauthkeys create --user ali --reusable --expiration 24h
On the client machine (with Tailscale installed):
sudo tailscale up --login-server https://headscale.example.com --authkey tskey-auth-...
Or use the interactive flow:
sudo tailscale up --login-server https://headscale.example.com
# Then browse to the URL shown and select your user
Verification
# On the server
docker exec headscale headscale nodes list
# On the client
tailscale status
tailscale ping 100.64.0.1
You should see both nodes with their 100.x IPs and direct or relay connectivity.
ACLs: Control Who Talks to Whom
Create config/acls.hujson:
{
"acls": [
{
"action": "accept",
"src": ["ali"],
"dst": ["ali:*"]
},
{
"action": "accept",
"src": ["tag:server"],
"dst": ["tag:server:*", "ali:22"]
}
],
"tagOwners": {
"tag:server": ["ali"]
}
}
Then set acl_policy_path: /etc/headscale/acls.hujson in config.yaml and restart.
Headscale vs. Alternatives
| Feature | Headscale | Tailscale (managed) | Plain WireGuard |
|---|---|---|---|
| Control plane | Self-hosted | Cloud | None (manual) |
| Device limit | Unlimited | 100 (free tier) | Unlimited |
| NAT traversal | Yes (DERP) | Yes | Manual port-forward |
| MagicDNS | Yes | Yes | No |
| ACLs | YAML/JSON | GUI + ACLs | iptables |
| Setup effort | Medium | Zero | High |
Next Steps
- Exit node: Route all client traffic through a Headscale node for secure browsing on untrusted networks
- Subnet routing: Advertise entire LANs (
tailscale up --advertise-routes=192.168.1.0/24) - SSO integration: Pair with Authentik or Keycloak for user provisioning
- Monitoring: Scrape Headscale metrics with Prometheus and visualize in Grafana
- Backup: Snapshot the
data/directory — it holds your keys and state
Headscale turns WireGuard from a manual, per-device chore into a modern, zero-config mesh — without surrendering your network map to a third party. For homelabs, small teams, and privacy-conscious deployments, it's the sweet spot.