mirror of
https://github.com/nickyeoman/docker-compose-cookbooks.git
synced 2026-10-22 20:16:22 +00:00
headscale: promote from _dev, split README into per-task docs
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>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# 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/`](docs/):
|
||||
|
||||
| Step | Task | Where |
|
||||
|---|---|---|
|
||||
| 1 | [Install Headscale](docs/install.md) | Dedicated server |
|
||||
| 2 | [Configure Nginx Proxy Manager](docs/nginx-proxy-manager.md) — **HTTP/2 off, Websockets on** | NPM |
|
||||
| 3 | [Create a user and pre-auth key](docs/users-and-keys.md) | Dedicated server |
|
||||
| 4 | [Connect the LAN node](docs/connect-lan-node.md) | LAN node |
|
||||
| 5 | [Connect phone / laptop](docs/connect-clients.md) | Clients |
|
||||
| 6 | [Verify and troubleshoot](docs/verify-and-troubleshoot.md) | Anywhere |
|
||||
| 7 | [Access LAN services (Jellyfin)](docs/access-services.md) | Clients |
|
||||
| 8 | [Admin UI](docs/admin-ui.md) | NPM + browser |
|
||||
|
||||
Optional / maintenance:
|
||||
|
||||
* [Subnet routing](docs/subnet-routing.md) — reach every LAN device, not just the node
|
||||
* [Revoke a compromised device](docs/revoke-device.md)
|
||||
|
||||
### Quick commands
|
||||
|
||||
```bash
|
||||
# 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 `proxy` network plus private `internal` bridge
|
||||
* No published ports — all access via NPM
|
||||
* `headscale` → NPM proxy host, `http://headscale:8080` (see [NPM guide](docs/nginx-proxy-manager.md))
|
||||
* `headscale-ui` → Custom Location `/admin` on the **same** proxy host, `http://headscale-ui:80` (a separate subdomain breaks via CORS — see [Admin UI](docs/admin-ui.md))
|
||||
* 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
|
||||
|
||||
```bash
|
||||
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_url` **must** match your public domain
|
||||
* NPM **HTTP/2 Support must be off** or `tailscale up` hangs (`/ts2021` returns 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 in `config.yaml` if 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.
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
headscale:
|
||||
image: ${HEADSCALE_IMAGE:-headscale/headscale:latest}
|
||||
restart: ${HEADSCALE_RESTART:-unless-stopped}
|
||||
volumes:
|
||||
- ${VOL_PATH:-/data}/headscale/config:/etc/headscale
|
||||
- ${VOL_PATH:-/data}/headscale/data:/var/lib/headscale
|
||||
- ${VOL_PATH:-/data}/headscale/logs:/var/log/headscale
|
||||
environment:
|
||||
- TZ=${TZ:-America/Vancouver}
|
||||
- HEADSCALE_LOG_LEVEL=${HEADSCALE_LOG_LEVEL:-info}
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
command:
|
||||
- serve
|
||||
|
||||
headscale-ui:
|
||||
image: ${HEADSCALE_UI_IMAGE:-goodieshq/headscale-admin:latest}
|
||||
restart: ${HEADSCALE_UI_RESTART:-unless-stopped}
|
||||
environment:
|
||||
- TZ=${TZ:-America/Vancouver}
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
depends_on:
|
||||
- headscale
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
internal:
|
||||
@@ -0,0 +1,33 @@
|
||||
server_url: https://headscale.example.com
|
||||
listen_addr: 0.0.0.0:8080
|
||||
|
||||
noise:
|
||||
private_key_path: /var/lib/headscale/noise_private.key
|
||||
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: /var/lib/headscale/db.sqlite
|
||||
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
auto_update_enabled: true
|
||||
update_frequency: 24h
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
dns:
|
||||
override_local_dns: true
|
||||
base_domain: tailnet.local
|
||||
nameservers:
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
@@ -0,0 +1,12 @@
|
||||
# Access LAN Services (e.g. Jellyfin)
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
With Tailscale connected on your phone/laptop, browse to:
|
||||
|
||||
* `http://<lan-node-tailscale-ip>:8096` (the 100.64.x.x address from `tailscale status`), or
|
||||
* `http://<lan-node-hostname>.tailnet.local:8096` via MagicDNS (base domain set in `config.yaml`)
|
||||
|
||||
Port 8096 only needs to be reachable on the LAN node itself — **never** forward it on your router.
|
||||
|
||||
MagicDNS base domain is `tailnet.local`; change it in `config.yaml` if it clashes with your LAN DNS.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Admin UI (headscale-admin)
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
The stack runs `headscale-ui` (goodieshq/headscale-admin), a browser app that talks to the headscale API using an API key.
|
||||
|
||||
**Serve it on a subpath of your headscale domain, not a separate subdomain.** The app's asset path is hardcoded to `/admin`, and on a different origin the browser blocks API calls via CORS (headscale sends no `Access-Control-Allow-Origin` headers, and adding them in NPM risks breaking the proxy host).
|
||||
|
||||
1. **Add a Custom Location** on the *existing* `headscale.example.com` proxy host in NPM:
|
||||
* Location: `/admin`
|
||||
* Scheme: `http`, Forward Hostname/IP: `headscale-ui`, Forward Port: `80`
|
||||
2. **Generate an API key** (shown only once):
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale apikeys create --expiration 90d
|
||||
```
|
||||
3. **Open** `https://headscale.example.com/admin/` and enter:
|
||||
* **Server URL:** `https://headscale.example.com`
|
||||
* **API Key:** the key from step 2
|
||||
|
||||
Manage keys with `headscale apikeys list` / `headscale apikeys expire`; mint a new one before the old one expires.
|
||||
|
||||
> Credentials are saved in each browser's `localStorage`. On another browser/device, enter the *same* key again — only generate a new key if it expired or was revoked.
|
||||
|
||||
`HEADSCALE_URL=http://headscale:8080` in `compose.yaml` only prefills the internal address; you still authenticate with an API key.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Connect Phone / Laptop
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
## Android / iOS
|
||||
|
||||
1. Install the Tailscale app (Play Store / App Store).
|
||||
2. Point it at your server:
|
||||
* **Android** (tested on v1.102.3, Galaxy S25 Ultra): **Settings → Add account**, tap the **three-dot menu (⋮)**, choose the alternate/custom coordination server option, enter `https://headscale.example.com`.
|
||||
* **iOS:** on the sign-in screen, choose **Alternate coordination server** before logging in.
|
||||
3. Log in. If the app shows a registration key instead of accepting a pre-auth key, approve it on the dedicated server:
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes register --user homelab --key <KEY-FROM-DEVICE>
|
||||
```
|
||||
|
||||
## Laptop / Desktop
|
||||
|
||||
Install the client (https://tailscale.com/download), then either use a pre-auth key:
|
||||
|
||||
```bash
|
||||
sudo tailscale up --login-server=https://headscale.example.com --authkey <YOUR-PREAUTH-KEY>
|
||||
```
|
||||
|
||||
Or approve interactively:
|
||||
|
||||
```bash
|
||||
sudo tailscale up --login-server=https://headscale.example.com
|
||||
# copy the printed key, then on the dedicated server:
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes register --user homelab --key <KEY-FROM-DEVICE>
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Connect the LAN Node
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
On the **LAN node** (the Docker host running Jellyfin etc.), install the Tailscale client.
|
||||
|
||||
**Debian/Ubuntu:**
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo systemctl enable --now tailscaled
|
||||
```
|
||||
|
||||
**Arch:**
|
||||
```bash
|
||||
sudo pacman -S tailscale
|
||||
sudo systemctl enable --now tailscaled
|
||||
```
|
||||
|
||||
Then on any distro:
|
||||
```bash
|
||||
sudo tailscale up --login-server=https://headscale.example.com --authkey <YOUR-PREAUTH-KEY>
|
||||
```
|
||||
|
||||
Firewall: allow UDP 41641 for direct WireGuard connections (Tailscale falls back to slower DERP relays if blocked).
|
||||
|
||||
To reach *other* LAN devices through this node, see [Subnet Routing](subnet-routing.md).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Install Headscale
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Run on the **dedicated server** (public IP).
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
Headscale will not start without a valid `config.yaml`, and `server_url` **must** match your public domain.
|
||||
|
||||
Next: [configure Nginx Proxy Manager](nginx-proxy-manager.md), then verify:
|
||||
|
||||
```bash
|
||||
curl https://headscale.example.com/health
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Configure Nginx Proxy Manager
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Add a proxy host 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
|
||||
* **SSL tab:**
|
||||
* Request a new Let's Encrypt certificate, force SSL
|
||||
* **Disable "HTTP/2 Support"** — important and easy to miss.
|
||||
|
||||
## Why HTTP/2 must be off
|
||||
|
||||
Tailscale clients register through a "noise" control protocol (`/ts2021`) that needs an upgrade handshake. Forcing HTTP/2 on this host silently breaks it, even though normal HTTPS requests (like `/health`) still work. Symptoms:
|
||||
|
||||
* `tailscale up` hangs indefinitely
|
||||
* headscale logs `no upgrade header in TS2021 request`
|
||||
* `/ts2021` requests return `status=500`
|
||||
|
||||
Don't disable caching/buffering unless you also see stalled long-poll connections — the important toggles are Websockets Support (on) and HTTP/2 Support (off).
|
||||
|
||||
## Admin UI
|
||||
|
||||
The admin panel goes on this **same** proxy host as a Custom Location (`/admin`), not a separate subdomain. See [Admin UI](admin-ui.md).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Revoke a Compromised Device
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes list
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes delete -i <NODE-ID>
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale preauthkeys expire --user homelab <KEY> # if the key leaked too
|
||||
```
|
||||
|
||||
The device is cut off immediately; re-join later with a fresh key if needed.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Subnet Routing (Optional)
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
Lets tailnet devices reach *everything* on the LAN, not just the LAN node.
|
||||
|
||||
On the **LAN node**:
|
||||
|
||||
```bash
|
||||
sudo tailscale up --login-server=https://headscale.example.com --advertise-routes=192.168.1.0/24
|
||||
|
||||
# enable IP forwarding
|
||||
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
|
||||
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
|
||||
```
|
||||
|
||||
Approve the route on the **dedicated server**:
|
||||
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes list-routes
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale nodes approve-routes -i <NODE-ID> --routes 192.168.1.0/24
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Create a User and Pre-Auth Key
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale -c /etc/headscale/config.yaml user create homelab
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale users list
|
||||
docker exec -it "$HEADSCALE_CONTAINER" headscale preauthkeys create --user 1 --expiration 1h
|
||||
```
|
||||
|
||||
Copy the printed key — you'll use it once on each device you join.
|
||||
|
||||
> **About `--expiration`:** it only limits the window in which the key can *register a new device*. Devices already joined stay authorized after the key expires. Use a longer expiration (e.g. `24h`) or `--reusable` when onboarding several devices.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Verify and Troubleshoot
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
Commands below use `$HEADSCALE_CONTAINER`. Set it once per shell session on the dedicated server (change the value if your compose project name differs; find it with `docker ps`):
|
||||
|
||||
```bash
|
||||
export HEADSCALE_CONTAINER=headscale-headscale-1
|
||||
```
|
||||
|
||||
```bash
|
||||
docker exec -it "$HEADSCALE_CONTAINER" 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
|
||||
```
|
||||
|
||||
A reply of `pong via ...` (direct or DERP) means the mesh works.
|
||||
|
||||
## `tailscale up` hangs or `/ts2021` returns 500
|
||||
|
||||
Almost always the reverse proxy — check [NPM settings](nginx-proxy-manager.md) (Websockets on, HTTP/2 off). To confirm, watch both sides while retrying `tailscale up`:
|
||||
|
||||
```bash
|
||||
journalctl -u tailscaled -f # on the client
|
||||
docker logs -f "$HEADSCALE_CONTAINER" # on the server
|
||||
```
|
||||
|
||||
`/ts2021` requests should return `status=200`, not `500`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Compose
|
||||
VOL_PATH=/data
|
||||
HEADSCALE_IMAGE=headscale/headscale:latest
|
||||
HEADSCALE_UI_IMAGE=goodieshq/headscale-admin:0.28
|
||||
HEADSCALE_RESTART=unless-stopped
|
||||
HEADSCALE_UI_RESTART=unless-stopped
|
||||
|
||||
# Headscale
|
||||
HEADSCALE_LOG_LEVEL=info
|
||||
HEADSCALE_DOMAIN=headscale.example.com
|
||||
HEADSCALE_URL=https://headscale.example.com
|
||||
TZ=America/Vancouver
|
||||
Reference in New Issue
Block a user