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:
code
2026-10-21 17:07:10 +00:00
co-authored by Claude Opus 5.5
parent 60e5197760
commit 665c27e7f4
17 changed files with 381 additions and 290 deletions
+12
View File
@@ -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.
+30
View File
@@ -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.
+36
View File
@@ -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>
```
+26
View File
@@ -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).
+24
View File
@@ -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
```
+27
View File
@@ -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).
+17
View File
@@ -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.
+28
View File
@@ -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
```
+19
View File
@@ -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.
+28
View File
@@ -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`.