- config.yaml: migrate to current headscale config schema (database/dns nested keys instead of legacy db_type/db_path/dns_config), add noise private key path, prefixes, and a DERP map (required at startup) - compose.yaml: bind-mount a fixed Caddyfile for headscale-ui so it serves from /app/admin instead of 404ing at /app - README: document NPM reverse proxy setup (websockets on, HTTP/2 off to avoid breaking the ts2021 noise protocol handshake), split Android/iOS vs laptop client instructions, add headscale-ui admin panel setup with API key generation, switch CLI examples to docker exec against the running container Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
Headscale
Overview
Headscale is a self-hosted control server for Tailscale clients. It lets you run your own private coordination plane for a WireGuard-based mesh network without relying on Tailscale's SaaS.
The homelab use case this stack is built for: securely reach services (Jellyfin, Ollama, n8n, etc.) running in Docker on a LAN node — from your phone or laptop, anywhere — without exposing those services to the internet.
[Phone / Laptop]
|
Tailscale client
|
[Dedicated Server] ← public IP, runs Headscale (this stack) behind a reverse proxy
|
Tailscale network (WireGuard mesh)
|
[LAN Node] ← Docker host on 192.168.x.x, runs Jellyfin etc., joins as a Tailscale client
The dedicated server acts only as the control plane — media traffic flows directly (peer-to-peer over WireGuard) between your device and the LAN node whenever possible. See index.html for a 15-minute quick-start checklist, and the walkthrough below for full details.
Requires a valid config.yaml before first startup (a starter is included in this directory).
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
All commands below run on the dedicated server unless noted otherwise.
1. Install Headscale on the dedicated server (this repo)
# Copy the starter config into place, then edit it
mkdir -p /data/headscale/config
cp config.yaml /data/headscale/config/config.yaml
nano /data/headscale/config/config.yaml # set server_url to YOUR domain, e.g. https://headscale.example.com
# Copy env defaults and start the stack
cp sample.env .env
docker compose up -d
Then point Nginx Proxy Manager at the headscale container, port 8080 (see Network Notes below) so https://headscale.example.com reaches it. Verify with:
curl https://headscale.example.com/health
2. Configure Nginx Proxy Manager
Add a proxy host in NPM for your headscale domain:
- Details tab:
- Domain Names:
headscale.example.com - Scheme:
http, Forward Hostname/IP:headscale, Forward Port:8080 - Enable: Websockets Support, Block Common Exploits
- Domain Names:
- SSL tab:
- Request a new Let's Encrypt certificate, force SSL
- Disable "HTTP/2 Support" — this is important and easy to miss. Tailscale clients register through a "noise" control protocol (
/ts2021) that needs an ALPN/websocket upgrade; forcing HTTP/2 on this host can silently break that handshake even though normal HTTPS requests (like/health) still work fine. Symptoms if you skip this:tailscale uphangs indefinitely, headscale logsno upgrade header in TS2021 request, and/ts2021requests returnstatus=500.
- Do not disable caching/buffering unless you also see stalled long-poll connections — the important toggles here are Websockets Support (on) and HTTP/2 Support (off).
Optional second proxy host for the admin UI — see Network Notes below.
3. Create a user and a pre-auth key
docker exec -it headscale-headscale-1 headscale -c /etc/headscale/config.yaml user create homelab
docker exec -it headscale-headscale-1 headscale users list
docker exec -it headscale-headscale-1 headscale preauthkeys create --user 1 --expiration 1h
Copy the key it prints — you'll use it once on each device you join.
Note on
--expiration: this only limits the window in which the key can be used to register a new device. Once a device has joined the tailnet with it, that device stays authorized regardless of the key's expiration — it doesn't get disconnected when the key expires. Register your device(s) within the window, or use a longer expiration (e.g.24h) /--reusableif you're onboarding multiple devices.
4. Connect the LAN node (the Docker host running Jellyfin)
On the LAN node, install the Tailscale client and point it at your Headscale server:
Debian/Ubuntu:
curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl enable --now tailscaled
Arch:
sudo pacman -S tailscale
sudo systemctl enable --now tailscaled
Then on any distro:
sudo tailscale up --login-server=https://headscale.example.com --authkey <YOUR-PREAUTH-KEY>
5. Connect your phone / laptop
Android / iOS
- Install the Tailscale app (Play Store / App Store).
- Android (tested on v1.102.3, Galaxy S25 Ultra): go to Settings → Add account, then tap the three-dot menu (⋮) that appears there and choose the alternate/custom coordination server option. Enter
https://headscale.example.com. iOS: on the sign-in screen, look for Alternate coordination server before logging in. - Log in. If the app shows a registration URL/code instead of accepting a pre-auth key directly, approve it from the dedicated server:
docker exec -it headscale-headscale-1 headscale nodes register --user homelab --key <KEY-FROM-DEVICE>
Laptop / desktop
Install the Tailscale client (https://tailscale.com/download), then run the same style of command used for the LAN node in step 4:
sudo tailscale up --login-server=https://headscale.example.com --authkey <YOUR-PREAUTH-KEY>
Or, if you'd rather approve interactively instead of using a pre-auth key:
sudo tailscale up --login-server=https://headscale.example.com
# copy the printed URL/key, then on the dedicated server:
docker exec -it headscale-headscale-1 headscale nodes register --user homelab --key <KEY-FROM-DEVICE>
6. Verify connectivity
docker exec -it headscale-headscale-1 headscale nodes list # on the dedicated server
tailscale status # on any client
tailscale ping <lan-node-hostname-or-100.64.x.x-ip> # from your laptop/phone terminal
A reply that says pong via ... (direct or DERP) means the mesh works.
Troubleshooting:
tailscale uphangs or fails with a 500 on/ts2021This almost always means the reverse proxy isn't configured correctly — see the NPM settings in step 2 (Websockets Support on, HTTP/2 Support off). To confirm: watch
journalctl -u tailscaled -fon the client anddocker logs ny-headscale-headscale-1(or your container name) on the server while retryingtailscale up. The/ts2021requests should returnstatus=200, not500.
7. Open Jellyfin
From your phone/laptop (with Tailscale connected), browse to:
http://<lan-node-tailscale-ip>:8096(the 100.64.x.x address fromtailscale status), orhttp://<lan-node-hostname>.tailnet.local:8096via MagicDNS (base domain set inconfig.yaml)
Jellyfin's port 8096 only needs to be reachable on the LAN node itself — never forward it on your router.
8. Set up the headscale-ui admin panel
The stack already runs headscale-ui (goodieshq/headscale-admin) alongside headscale. It's a browser app that talks to headscale's API directly using an API key — it doesn't share auth with the NPM proxy host, so it needs its own setup:
Note: the
goodieshq/headscale-adminimage serves its static files from/app/admin, but its default Caddyfile roots at/app— so requests to/404 out of the box (only/admin/works).compose.yamlfixes this by bind-mounting the includedCaddyfile(root * /app/admin) over/etc/caddy/Caddyfile, so/resolves correctly. If you're not using that mount, browse to/admin/instead.
- Add a proxy host in NPM for the UI (see Network Notes for exact settings) — e.g.
headscale-admin.example.com→ forward toheadscale-ui:80. - Generate an API key:
Copy the printed key — headscale won't show it again.
docker exec -it headscale-headscale-1 headscale apikeys create --expiration 90d - Open the UI at
https://headscale-admin.example.comand enter:- Server URL: your public headscale URL (
https://headscale.example.com), not the internalhttp://headscale:8080 - API Key: the key from step 2
- Server URL: your public headscale URL (
- You should now see users, nodes, and pre-auth keys manageable from the browser.
Re-run step 2 to mint a fresh key before the old one expires (headscale apikeys list / headscale apikeys expire to manage existing keys).
Optional: subnet routing (reach other LAN devices)
If you want your tailnet devices to reach everything on the LAN (not just the LAN node), advertise the subnet from the LAN node:
sudo tailscale up --login-server=https://headscale.example.com --advertise-routes=192.168.1.0/24
# then approve the route on the dedicated server:
docker exec -it headscale-headscale-1 headscale nodes list-routes
docker exec -it headscale-headscale-1 headscale nodes approve-routes -i <NODE-ID> --routes 192.168.1.0/24
Also enable IP forwarding on the LAN node:
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
Revoking a compromised device
docker exec -it headscale-headscale-1 headscale nodes list
docker exec -it headscale-headscale-1 headscale nodes delete -i <NODE-ID>
docker exec -it headscale-headscale-1 headscale preauthkeys expire --user homelab <KEY> # if the key leaked too
The device is cut off immediately; re-join it later with a fresh key if needed.
Environment Variable Notes
These variables are used in the Docker Compose file (.env):
HEADSCALE_IMAGE – Docker image for Headscale server (default: headscale/headscale:latest)
HEADSCALE_UI_IMAGE – Docker image for admin UI
HEADSCALE_RESTART – Restart policy (default: unless-stopped)
HEADSCALE_UI_RESTART – Restart policy for UI container
# Paths
VOL_PATH – Base path for config/data/logs volumes (default: /data)
# System
TZ – Timezone (e.g. America/Vancouver)
HEADSCALE_LOG_LEVEL – Logging verbosity (info, debug, etc.)
# Documented for config.yaml (not read by compose):
HEADSCALE_DOMAIN – Public-facing domain used via reverse proxy (e.g. headscale.example.com)
HEADSCALE_URL – Full URL used by clients; must match server_url in config.yaml
Volume Notes
All data is stored locally on the host for full self-sovereignty:
${VOL_PATH}/headscale/config → Contains config.yaml (required configuration file)
${VOL_PATH}/headscale/data → SQLite database and persistent Headscale state
${VOL_PATH}/headscale/logs → Optional logs for debugging and monitoring
Back up data/ and config/ regularly for recovery.
Network Notes
- Uses the external
proxynetwork plus a private bridge networkinternal - No public ports exposed directly — all access is routed through the reverse proxy (NPM)
- Reverse proxy requirements (Nginx Proxy Manager), for
headscale.example.com:- Forward hostname:
headscale, forward port:8080, scheme:http - Enable: Websockets Support ✔, Block Common Exploits ✔, SSL (Let's Encrypt) ✔, disable caching ✔
- Forward hostname:
headscale-uiadmin panel: add a second proxy host on a separate subdomain (e.g.headscale-admin.example.com) so you can reach it from a browser — see step 8 for the full setup including the API key:- Forward hostname:
headscale-ui, forward port:80, scheme:http - Enable: Block Common Exploits ✔, SSL (Let's Encrypt) ✔
HEADSCALE_URL=http://headscale:8080(see compose.yaml) only prefills the internal address in the UI — you still authenticate the browser session yourself with an API key (step 8)
- Forward hostname:
Firewall considerations on the dedicated server: only 80/443 (reverse proxy) need to be open inbound. On the LAN node: allow UDP 41641 outbound/inbound for direct WireGuard connections (Tailscale falls back to DERP relays if blocked, just slower). Do not forward 8096 (Jellyfin) or 8080 (Headscale) 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 \
headscale serve
See compose.yaml for the full set of environment variables.
Additional Notes / Gotchas
- Headscale will not function without a valid
config.yaml server_urlinsideconfig.yamlMUST match your public domain- Clients must explicitly point to your server:
tailscale up --login-server=https://headscale.example.com - Do NOT expose port 8080 publicly — only via reverse proxy
- SQLite is used by default (no external DB required)
- MagicDNS base domain is
tailnet.local(set inconfig.yaml); change it if it clashes with your LAN DNS
Dockhand Stack, Deploy from Git
Cookbooks Repository stackname: headscale_dev Compose file path: headscale_dev/compose.yaml Additional env file (optional): headscale_dev/sample.env
Then "Load" headscale_dev/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.