diff --git a/AGENTS.md b/AGENTS.md index c1be4e7..04c5585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ These deliberately violate the conventions above — do not "fix" them: - `nextcloud/compose-aio.yaml` follows the upstream AIO layout: fixed container/volume names, no env templating, no proxy network - `opencode` and `vscode` default `VOL_PROJECTS` to `${VOL_PATH:-/data}/projects` (nested default), not plain `/data` - `mailu_dev` passes env via `env_file: .env`, so its sample.env holds vars the compose file never references -- `headscale_dev` sample.env documents `HEADSCALE_DOMAIN`/`HEADSCALE_URL` for the config file even though compose doesn't use them +- `headscale` sample.env documents `HEADSCALE_DOMAIN`/`HEADSCALE_URL` for the config file even though compose doesn't use them ## No CI/CD, no dependency management diff --git a/_docs/lint.py b/_docs/lint.py index 47594d5..daf050d 100644 --- a/_docs/lint.py +++ b/_docs/lint.py @@ -26,7 +26,7 @@ NO_PROXY_OK = {"pihole"} HARDCODED_RESTART_OK = {("zammad_dev", "on-failure")} STALE_VARS_OK = { "mailu_dev": None, # env_file: .env — all vars consumed in-container - "headscale_dev": {"HEADSCALE_DOMAIN", "HEADSCALE_URL"}, # config-file docs + "headscale": {"HEADSCALE_DOMAIN", "HEADSCALE_URL"}, # config-file docs } errors = [] diff --git a/headscale/README.md b/headscale/README.md new file mode 100644 index 0000000..c1edf1d --- /dev/null +++ b/headscale/README.md @@ -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 +``` + +## 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. diff --git a/headscale_dev/compose.yaml b/headscale/compose.yaml similarity index 100% rename from headscale_dev/compose.yaml rename to headscale/compose.yaml diff --git a/headscale_dev/config.yaml b/headscale/config.yaml similarity index 100% rename from headscale_dev/config.yaml rename to headscale/config.yaml diff --git a/headscale/docs/access-services.md b/headscale/docs/access-services.md new file mode 100644 index 0000000..f358aeb --- /dev/null +++ b/headscale/docs/access-services.md @@ -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://:8096` (the 100.64.x.x address from `tailscale status`), or +* `http://.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. diff --git a/headscale/docs/admin-ui.md b/headscale/docs/admin-ui.md new file mode 100644 index 0000000..851499a --- /dev/null +++ b/headscale/docs/admin-ui.md @@ -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. diff --git a/headscale/docs/connect-clients.md b/headscale/docs/connect-clients.md new file mode 100644 index 0000000..80212e6 --- /dev/null +++ b/headscale/docs/connect-clients.md @@ -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 + ``` + +## 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 +``` + +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 +``` diff --git a/headscale/docs/connect-lan-node.md b/headscale/docs/connect-lan-node.md new file mode 100644 index 0000000..859fdf8 --- /dev/null +++ b/headscale/docs/connect-lan-node.md @@ -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 +``` + +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). diff --git a/headscale/docs/install.md b/headscale/docs/install.md new file mode 100644 index 0000000..0c32698 --- /dev/null +++ b/headscale/docs/install.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 +``` diff --git a/headscale/docs/nginx-proxy-manager.md b/headscale/docs/nginx-proxy-manager.md new file mode 100644 index 0000000..3abdfea --- /dev/null +++ b/headscale/docs/nginx-proxy-manager.md @@ -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). diff --git a/headscale/docs/revoke-device.md b/headscale/docs/revoke-device.md new file mode 100644 index 0000000..1d04ecc --- /dev/null +++ b/headscale/docs/revoke-device.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 +docker exec -it "$HEADSCALE_CONTAINER" headscale preauthkeys expire --user homelab # if the key leaked too +``` + +The device is cut off immediately; re-join later with a fresh key if needed. diff --git a/headscale/docs/subnet-routing.md b/headscale/docs/subnet-routing.md new file mode 100644 index 0000000..4fe697d --- /dev/null +++ b/headscale/docs/subnet-routing.md @@ -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 --routes 192.168.1.0/24 +``` diff --git a/headscale/docs/users-and-keys.md b/headscale/docs/users-and-keys.md new file mode 100644 index 0000000..08f5fc5 --- /dev/null +++ b/headscale/docs/users-and-keys.md @@ -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. diff --git a/headscale/docs/verify-and-troubleshoot.md b/headscale/docs/verify-and-troubleshoot.md new file mode 100644 index 0000000..c29a035 --- /dev/null +++ b/headscale/docs/verify-and-troubleshoot.md @@ -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 # 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`. diff --git a/headscale_dev/sample.env b/headscale/sample.env similarity index 100% rename from headscale_dev/sample.env rename to headscale/sample.env diff --git a/headscale_dev/README.md b/headscale_dev/README.md deleted file mode 100644 index 3496eb7..0000000 --- a/headscale_dev/README.md +++ /dev/null @@ -1,288 +0,0 @@ -# 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) - -```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 -``` - -Then point Nginx Proxy Manager at the `headscale` container, port `8080` (see Network Notes below) so `https://headscale.example.com` reaches it. Verify with: - -```bash -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 -* **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 up` hangs indefinitely, headscale logs `no upgrade header in TS2021 request`, and `/ts2021` requests return `status=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](#network-notes) below. - -### 3. Create a user and a pre-auth key - -```bash -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`) / `--reusable` if 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:** -```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 -``` - -### 5. Connect your phone / laptop - -#### Android / iOS - -1. Install the Tailscale app (Play Store / App Store). -2. **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. -3. Log in. If the app shows a registration URL/code instead of accepting a pre-auth key directly, approve it from the dedicated server: - ```bash - docker exec -it headscale-headscale-1 headscale nodes register --user homelab --key - ``` - -#### 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: - -```bash -sudo tailscale up --login-server=https://headscale.example.com --authkey -``` - -Or, if you'd rather approve interactively instead of using a pre-auth key: - -```bash -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 -``` - -### 6. Verify connectivity - -```bash -docker exec -it headscale-headscale-1 headscale nodes list # on the dedicated server -tailscale status # on any client -tailscale ping # from your laptop/phone terminal -``` - -A reply that says `pong via ...` (direct or DERP) means the mesh works. - -> **Troubleshooting: `tailscale up` hangs or fails with a 500 on `/ts2021`** -> -> This almost always means the reverse proxy isn't configured correctly — see the NPM settings in [step 2](#2-configure-nginx-proxy-manager) (Websockets Support on, HTTP/2 Support off). To confirm: watch `journalctl -u tailscaled -f` on the client and `docker logs ny-headscale-headscale-1` (or your container name) on the server while retrying `tailscale up`. The `/ts2021` requests should return `status=200`, not `500`. - -### 7. Open Jellyfin - -From your phone/laptop (with Tailscale connected), browse to: - -* `http://:8096` (the 100.64.x.x address from `tailscale status`), or -* `http://.tailnet.local:8096` via MagicDNS (base domain set in `config.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. - -**Serve it same-origin, on a subpath of your headscale domain — not a separate subdomain.** The app is a SvelteKit build with its asset base path hardcoded at build time to `/admin` (its files live at `/app/admin` inside the image), and if it's on a different origin than the headscale API, the browser blocks the API calls via CORS — headscale doesn't send `Access-Control-Allow-Origin` headers, and there's no supported way to add them via NPM without risking breaking the whole proxy host (ask if you're curious what happened when we tried). Serving both from the same domain sidesteps CORS entirely and matches how the app expects to be deployed. - -1. **Add a Custom Location on the *existing* `headscale.example.com` proxy host in NPM** (don't create a separate proxy host/subdomain for this): - * Location: `/admin` - * Scheme: `http`, Forward Hostname/IP: `headscale-ui`, Forward Port: `80` -2. **Generate an API key:** - ```bash - docker exec -it headscale-headscale-1 headscale apikeys create --expiration 90d - ``` - Copy the printed key — headscale won't show it again. -3. **Open the UI** at `https://headscale.example.com/admin/` and enter: - * **Server URL:** `https://headscale.example.com` (same origin as the page you're on) - * **API Key:** the key from step 2 -4. 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). - -> **Note:** the server URL + API key are saved in that browser's `localStorage`, per browser/device — logging in from a different computer or browser just needs the *same* credentials entered again, not a new key. Only generate a new key if the old one actually expires or you've revoked it. - -### 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: - -```bash -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 --routes 192.168.1.0/24 -``` - -Also enable IP forwarding on the LAN node: - -```bash -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 - -```bash -docker exec -it headscale-headscale-1 headscale nodes list -docker exec -it headscale-headscale-1 headscale nodes delete -i -docker exec -it headscale-headscale-1 headscale preauthkeys expire --user homelab # 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 `proxy` network plus a private bridge network `internal` -* 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 ✔ -* `headscale-ui` admin panel: add a **Custom Location** (`/admin`) on the *same* `headscale.example.com` proxy host — not a separate subdomain, which triggers CORS failures against the headscale API. See [step 8](#8-set-up-the-headscale-ui-admin-panel) for the full setup including the API key: - * Location: `/admin`, forward hostname: `headscale-ui`, forward port: `80`, scheme: `http` - * `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) - -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 - -```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 \ - 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_url` inside `config.yaml` MUST 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 in `config.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.