Move step-by-step guides into headscale/docs/, slim README to a quick reference, and use $HEADSCALE_CONTAINER for docker exec commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.0 KiB
Headscale
Overview
Self-hosted Tailscale control server. Reach homelab services (Jellyfin, Ollama, n8n, etc.) on a LAN node from your phone or laptop, anywhere — without exposing them to the internet.
[Phone / Laptop] → Tailscale client
|
[Dedicated Server] ← public IP, runs Headscale (this stack) behind NPM
|
WireGuard mesh (peer-to-peer when possible)
|
[LAN Node] ← Docker host on 192.168.x.x, joins as a Tailscale client
The dedicated server is only the control plane — traffic flows directly between devices whenever possible.
Project Details
- Project Repository: https://github.com/juanfont/headscale
- Container Image: https://hub.docker.com/r/headscale/headscale
- Admin UI Image: https://hub.docker.com/r/goodieshq/headscale-admin
- Documentation: https://headscale.net/
- Reverse Proxy Domain: headscale.example.com
- Internal Service Port: 8080 (not exposed publicly)
Getting Started
Follow in order — each step has its own guide in docs/:
| Step | Task | Where |
|---|---|---|
| 1 | Install Headscale | Dedicated server |
| 2 | Configure Nginx Proxy Manager — HTTP/2 off, Websockets on | NPM |
| 3 | Create a user and pre-auth key | Dedicated server |
| 4 | Connect the LAN node | LAN node |
| 5 | Connect phone / laptop | Clients |
| 6 | Verify and troubleshoot | Anywhere |
| 7 | Access LAN services (Jellyfin) | Clients |
| 8 | Admin UI | NPM + browser |
Optional / maintenance:
- Subnet routing — reach every LAN device, not just the node
- Revoke a compromised device
Quick commands
# Set the headscale container name once per shell (server; change if your compose project name differs — check with `docker ps`)
export HEADSCALE_CONTAINER=headscale-headscale-1
# List all registered devices (server)
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes list
# Create a one-hour pre-auth key for user ID 1 to join a new device (server)
docker exec -it "$HEADSCALE_CONTAINER" headscale preauthkeys create --user 1 --expiration 1h
# Create a 90-day API key for the admin UI (server)
docker exec -it "$HEADSCALE_CONTAINER" headscale apikeys create --expiration 90d
# Join this machine to the tailnet using a pre-auth key (client)
sudo tailscale up --login-server=https://headscale.example.com --authkey <KEY>
Environment Variable Notes
HEADSCALE_IMAGE – Headscale image (default: headscale/headscale:latest)
HEADSCALE_UI_IMAGE – Admin UI image (default: goodieshq/headscale-admin:latest)
HEADSCALE_RESTART / HEADSCALE_UI_RESTART – Restart policy (default: unless-stopped)
VOL_PATH – Base path for volumes (default: /data)
TZ – Timezone (default: America/Vancouver)
HEADSCALE_LOG_LEVEL – info, debug, etc.
# Documented for config.yaml (not read by compose):
HEADSCALE_DOMAIN – Public domain (e.g. headscale.example.com)
HEADSCALE_URL – Full client URL; must match server_url in config.yaml
Volume Notes
${VOL_PATH}/headscale/config → config.yaml (required before first start)
${VOL_PATH}/headscale/data → SQLite database and state
${VOL_PATH}/headscale/logs → Logs
Back up config/ and data/ regularly.
Network Notes
- External
proxynetwork plus privateinternalbridge - No published ports — all access via NPM
headscale→ NPM proxy host,http://headscale:8080(see NPM guide)headscale-ui→ Custom Location/adminon the same proxy host,http://headscale-ui:80(a separate subdomain breaks via CORS — see Admin UI)- Firewall: dedicated server only needs 80/443 inbound; LAN node should allow UDP 41641 for direct WireGuard
- Never forward 8080 (Headscale) or 8096 (Jellyfin) on any router
Docker Run
docker run -d \
--name headscale \
-v /data/headscale/config:/etc/headscale \
-v /data/headscale/data:/var/lib/headscale \
-v /data/headscale/logs:/var/log/headscale \
headscale/headscale:latest \
serve
Additional Notes / Gotchas
- No valid
config.yaml→ Headscale won't start;server_urlmust match your public domain - NPM HTTP/2 Support must be off or
tailscale uphangs (/ts2021returns 500) - Pre-auth key expiration only limits new registrations — joined devices stay connected
- SQLite by default (no external DB)
- MagicDNS base domain is
tailnet.local; change it inconfig.yamlif it clashes with your LAN DNS
Dockhand Stack, Deploy from Git
Cookbooks Repository stackname: headscale Compose file path: headscale/compose.yaml Additional env file (optional): headscale/sample.env
Then "Load" headscale/sample.env into the Environmental variables in dockhand.
Ensure config.yaml exists at ${VOL_PATH}/headscale/config/config.yaml before deploying.
Create the Stack, then configure the reverse proxy in Nginx Proxy Manager.