Tailscale is wonderful, but the free tier caps users and the control plane is a third party you have to trust with your network metadata. If that bothers you, or you just want to know exactly where your coordination server lives, Headscale is the answer.
Headscale is an open-source, self-hosted implementation of the Tailscale control server. The official Tailscale clients (macOS, iOS, Android, Windows, Linux) connect to it without modification, you keep all the magic - magic DNS, NAT traversal, subnet routing, exit nodes - and you stop sending node metadata to a SaaS.
This guide walks through a clean Headscale deployment on a VPS: Docker, Caddy for automatic HTTPS, a real user, a pre-auth key, a Linux node joining the tailnet, and an optional web UI for managing it all.
headscale.example.com at your VPS80 and 443docker-compose.ymltailscale up --login-serverheadscale-admin web UI to manage users from a browserTotal time: around 20 minutes.
80 and 443 open to the internet (Let's Encrypt)If you have not picked a VPS yet, anything in the entry tier is enough. Headscale itself idles around 30 MB of RAM.
Both are great, and they share clients. Pick based on operational appetite.
If you are coming from plain WireGuard, see our WireGuard guide for a comparison of how the two protocols differ in shape.
In your DNS provider, add an A record:
headscale.example.com → YOUR_VPS_IPV4
Add an AAAA record too if you have IPv6. Verify before continuing:
dig +short headscale.example.com
The output must match the VPS IP. Caddy needs a working DNS record before it can request a certificate.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Sanity-check:
docker --version
docker compose version
If you use UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Headscale itself does not need any extra UDP ports for the basics. Clients use the public DERP relays Tailscale operates by default. We will switch to a private DERP later if you want.
sudo mkdir -p /opt/headscale/{config,data,caddy-data,caddy-config}
cd /opt/headscale
Everything Headscale persists lives under /opt/headscale. That is the one path you need to back up.
Create /opt/headscale/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:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
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:
level: info
format: text
dns:
magic_dns: true
base_domain: tailnet.example.com
nameservers:
global:
- 1.1.1.1
- 9.9.9.9
search_domains: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
policy:
mode: file
path: ""
Two things that bite people:
base_domain must be a domain you control, and it must not be the same as server_url's host. Pick something like tailnet.example.com or ts.example.com. You do not need to publish it on real DNS - magic DNS handles resolution inside the tailnet.prefixes.v4 is the carrier-grade NAT range Tailscale uses (100.64.0.0/10). Leave it unless you have a strong reason.Set ownership so the container can read it:
sudo chown -R 1000:1000 /opt/headscale
Create /opt/headscale/docker-compose.yml:
services:
headscale:
image: headscale/headscale:0.26.1
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
networks:
- hsnet
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- hsnet
networks:
hsnet:
Pin headscale/headscale to a specific tag - Headscale moves quickly and a latest tag has bitten people during minor version bumps. Check the project's release page for the current stable tag.
Create /opt/headscale/Caddyfile:
headscale.example.com {
encode zstd gzip
reverse_proxy headscale:8080
}
Caddy will request a Let's Encrypt cert on first boot and renew it on its own. Tailscale's noise protocol runs over plain HTTPS, so a single reverse-proxy block covers everything the clients need.
cd /opt/headscale
sudo docker compose up -d
sudo docker compose logs -f headscale
Once you see listening and serving HTTP on 0.0.0.0:8080 and Caddy reports a successful cert, hit the URL from your laptop:
curl -sSI https://headscale.example.com/health
You should get HTTP/2 200. Anything else, jump to the troubleshooting section.
Headscale ships its admin CLI inside the image. Run it through docker exec:
sudo docker exec headscale \
headscale users create alice
Take note of the user ID printed in the output (usually 1 for the first user).
Now mint a pre-auth key. Pre-auth keys let nodes join without a browser flow, which is what you want on headless servers and CI runners.
sudo docker exec headscale \
headscale preauthkeys create --user 1 --reusable --expiration 24h
Copy the key (a long hex string) somewhere you trust. It is only printed once.
On the Linux box you want to join, install the official Tailscale client (yes, official - Headscale only replaces the control plane):
curl -fsSL https://tailscale.com/install.sh | sh
Then bring it up against your Headscale instance:
sudo tailscale up \
--login-server https://headscale.example.com \
--authkey YOUR_PREAUTH_KEY_HERE \
--accept-dns=true
Verify on the server:
sudo docker exec headscale headscale nodes list
You should see your hostname, the user alice, and an IP from 100.64.0.0/10. From any other node in the tailnet, that IP should now ping.
The App Store builds talk only to Tailscale's coordinator by default. For Headscale you want the alternate builds:
Tailscale-x.y.z-headscale.pkg bundle from the Headscale releases wiki.https://headscale.example.com/apple in Safari and follow the profile-install flow it generates.Once installed, use the gear icon to override the coordination URL, then sign in with a fresh pre-auth key. Same drill as Linux.
Headscale's CLI is fine, but a UI makes daily ops easier. headscale-admin is a single-page app that talks to Headscale's API.
First, mint an API key:
sudo docker exec headscale \
headscale apikeys create --expiration 90d
Add a service to /opt/headscale/docker-compose.yml:
admin:
image: ghcr.io/goodieshq/headscale-admin:latest
container_name: headscale-admin
restart: unless-stopped
networks:
- hsnet
Append a route to the Caddyfile so it lives at /admin:
headscale.example.com {
encode zstd gzip
handle_path /admin* {
reverse_proxy admin:80
}
reverse_proxy headscale:8080
}
Reload:
sudo docker compose up -d
sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Visit https://headscale.example.com/admin, paste your API key and the base URL, and you have a graphical view of users, nodes, pre-auth keys, and routes.
Everything Headscale persists lives in /opt/headscale. The SQLite database is small (a few MB even with hundreds of nodes) and safe to copy with the .backup SQLite command.
Create /usr/local/bin/headscale-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/headscale"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
docker exec headscale \
sqlite3 /var/lib/headscale/db.sqlite \
".backup '/var/lib/headscale/db.backup.sqlite'"
tar -czf "$BACKUP_DIR/headscale-$DATE.tar.gz" \
-C /opt/headscale config data
find "$BACKUP_DIR" -name "headscale-*.tar.gz" -mtime +14 -delete
Make it executable and schedule it:
sudo chmod +x /usr/local/bin/headscale-backup.sh
echo "20 3 * * * root /usr/local/bin/headscale-backup.sh" | \
sudo tee /etc/cron.d/headscale-backup
For off-site safety, push the resulting tarball to object storage with restic on the same schedule.
Caddy cannot issue a certificate. DNS has not propagated, or ports 80/443 are blocked at the provider firewall. Check with dig +short and your provider's network panel.
Clients connect but cannot reach each other. Magic DNS is off or base_domain is wrong. Confirm headscale nodes list shows IPs and that each node has --accept-dns=true. From a node, tailscale status should list peers.
headscale serve fails with "noise private key" errors. Ownership inside the container is wrong. Re-run sudo chown -R 1000:1000 /opt/headscale and restart.
iOS profile install loops. You set server_url to a host that does not match the cert Caddy issued. The server_url in config.yaml and the certificate hostname have to be byte-for-byte the same.
Pre-auth keys reject with "expired". Headscale uses UTC. If your VPS clock has drifted, NTP is broken. Run timedatectl status and re-enable systemd-timesyncd.
tailscale up --advertise-routes=10.0.0.0/24 plus headscale routes enable lets a tailnet member act as a gateway into a private LAN. Same pattern works for exit nodes./opt/headscale/config/acl.hujson and point policy.path at it to lock down which users can reach which nodes.derp.server.enabled and open UDP 3478.That's it. You now have your own coordination server on a VPS you control, talking the same protocol the official clients already speak.
Need a VPS that's a good fit for a coordination server like this? Our Linux plans include fast NVMe storage, IPv6, and a generous bandwidth allowance. See the options.