HOW-TO
Deploying your own coordination plane, start to finish. Headscale's config schema has broken between versions before — check yours against the official docs if a step here doesn't match.
-
01 Install Docker
The only real dependency. Docker Desktop on Mac/Windows, or just the Docker Engine on Linux.
brew install --cask docker
-
02 Write docker-compose.yml
Defines the headscale container. command: serve, not headscale serve — the binary name is already the entrypoint.
services: headscale: image: headscale/headscale:latest container_name: headscale command: serve volumes: - ./config:/etc/headscale - ./data:/var/lib/headscale ports: - "8080:8080" - "9090:9090" restart: unless-stopped -
03 Write config/config.yaml
Current schema as of headscale v0.29.x. Needs at least one DERP entry or it refuses to boot — see Field Notes #2.
server_url: http://YOUR_LAN_IP:8080 listen_addr: 0.0.0.0:8080 metrics_listen_addr: 0.0.0.0:9090 noise: private_key_path: /var/lib/headscale/noise_private.key prefixes: v4: 100.64.0.0/10 v6: fd7a:115c:a1e0::/48 derp: server: enabled: false urls: - https://controlplane.tailscale.com/derpmap/default database: type: sqlite sqlite: path: /var/lib/headscale/db.sqlite policy: mode: file path: /etc/headscale/acl.json dns: magic_dns: true base_domain: yourdomain.internal nameservers: global: - 1.1.1.1 -
04 Write config/acl.json
Closed-by-default — only devices under the same user can reach each other. Not the wide-open accept-all default.
{ "groups": { "group:home": ["youruser@"] }, "acls": [ { "action": "accept", "src": ["group:home"], "dst": ["group:home:*"] } ] } -
05 Start the stack
Confirm it's actually staying up, not crash-looping — check twice, a few seconds apart.
docker compose up -d docker ps
-
06 Create a user and a pre-auth key
Use the numeric user ID, not the name, for preauthkeys — see Field Notes #4.
docker exec -it headscale headscale users create myuser docker exec -it headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
-
07 Connect your first device
If Tailscale's already managing this device for another tailnet, use login (adds a profile) rather than up --reset (replaces the active one).
sudo tailscale login --login-server=http://YOUR_LAN_IP:8080 --authkey=YOUR_KEY