How to Self-Host the Tailscale Control Server with Headscale

Tailscale, without Tailscale's servers: Headscale is the coordination server, self-hosted, with the same apps on every device.

Tailscale (tutorial 47) is wonderful, and it depends on Tailscale's coordination servers: the keys and device list live with them, and the free plan has limits. Headscale is an open-source reimplementation of that coordination server. The devices still run the ordinary Tailscale apps; they just register with your server instead. Same MagicDNS, subnet routes and exit nodes, no account with anyone. The trade: you host one more service, and it needs a public HTTPS address.

Quick reference

TaskWhat
Containerheadscale/headscale behind vpn.example.com (HTTPS, public)
Config./config/config.yaml with server_url and dns.base_domain
A userheadscale users create john
Join a devicetailscale up --login-server https://vpn.example.com → approve with headscale nodes register
Pre-auth key (no approval step)headscale preauthkeys create --user john --reusable --expiration 24h
See devicesheadscale nodes list

1. Why the public address is unavoidable

Devices out in the world must reach the coordination server to find each other, so Headscale needs a name that resolves from anywhere with a valid certificate — through your reverse proxy with Let's Encrypt (tutorials 27–28), or a Cloudflare Tunnel (109). The actual VPN traffic still goes device-to-device (or via DERP relays when it can't); only the small coordination traffic touches the server.

2. The compose file

mkdir -p ~/docker/headscale/{config,data} && cd ~/docker/headscale
nano compose.yaml

services:
headscale:
image: headscale/headscale:latest
container_name: headscale
command: serve
ports:
- "127.0.0.1:8085:8080"
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
restart: unless-stopped

nano config/config.yaml

The project ships an example config; the lines that matter:

server_url: https://vpn.example.com
listen_addr: 0.0.0.0:8080
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp:
urls:
- https://controlplane.tailscale.com/derpmap/default
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
dns:
magic_dns: true
base_domain: ts.example.com
nameservers:
global: [192.168.1.50]

base_domain must differ from server_url's domain part. The derp.urls line borrows Tailscale's public relays for devices that can't connect directly — or run your own DERP container later.

docker compose up -d

3. Reverse proxy

vpn.example.com → 127.0.0.1:8085 with the WebSocket lines (tutorial 28) and a certificate. Test: https://vpn.example.com/health returns {"status":"pass"}.

4. Users and devices

docker compose exec headscale headscale users create john

On a device (laptop, Pi, phone with the Tailscale app):

sudo tailscale up --login-server https://vpn.example.com

It prints a URL with a key; on the server:

docker compose exec headscale headscale nodes register --user john --key mkey:xxxx
docker compose exec headscale headscale nodes list

For devices that can't do the dance (a server you're scripting), make a pre-auth key:

docker compose exec headscale headscale preauthkeys create --user john --reusable --expiration 24h
sudo tailscale up --login-server https://vpn.example.com --authkey <key>

Phones: in the Tailscale app, tap the three dots on the login screen → Use an alternate server → your URL.

5. Routes and exit nodes

Advertise from the server as in tutorial 47 (--advertise-routes=192.168.1.0/24 --advertise-exit-node), then approve on Headscale:

docker compose exec headscale headscale nodes list-routes
docker compose exec headscale headscale nodes approve-routes -i 1 -r 192.168.1.0/24,0.0.0.0/0,::/0

MagicDNS names are device.ts.example.com.

6. A web UI

Headscale is command-line by design. headscale-admin or headplane are community web UIs that use its API (headscale apikeys create) — handy once there are more than a few devices.

What you give up

Tailscale's SSO login, the slick admin console, Tailscale SSH, Funnel, and a company keeping the DERP relays up. What you keep: the apps, the mesh, the speed, routes, exit nodes, MagicDNS — and the knowledge that no third party holds the keys to your network.

Troubleshooting

tailscale up hangs after printing the URL. It's waiting for you to run nodes register. Or server_url isn't reachable from that device.

Devices connect but can't ping each other. Both are behind hard NAT and relays failed; check the derp config, or run your own DERP.

"base_domain must not be a subdomain of server_url". Pick a different dns.base_domain.

Lost after a restart. ./data holds the keys and database — it must persist (and be backed up).

Wrapping up

One container with a config, a public HTTPS name through the proxy, headscale users create, tailscale up --login-server. The same apps on every device, routes and exit nodes as before, and the coordination server — the thing that knows every device and key — is a folder on your own server.

Call us