headscale_dev: consolidate README and add index.html quick start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-16 18:19:59 +00:00
co-authored by Claude Fable 5
parent 102c207bfc
commit cf4336b462
2 changed files with 255 additions and 86 deletions
+154 -86
View File
@@ -1,12 +1,26 @@
# Heascale
# Headscale
## Overview
Headscale is a self-hosted control server for Tailscale clients. It allows you to run your own private coordination plane for a WireGuard-based mesh network without relying on external SaaS services.
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.
This deployment is designed to run fully self-hosted behind a reverse proxy.
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**.
Requires a valid config.yaml before first startup.
```
[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
@@ -16,77 +30,149 @@ Requires a valid config.yaml before first startup.
* Documentation: https://headscale.net/
* Reverse Proxy Domain: headscale.example.com
* Internal Service Port: 8080 (not exposed publicly)
* Environment Variable Notes
## Getting Started
1. Start the container: `docker compose up -d`
2. Open http://localhost:PORT in your browser
3. Follow the initial setup wizard to configure the application
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 it (see Network Notes below) so `https://headscale.example.com` reaches the container. Verify with:
```bash
curl https://headscale.example.com/health
```
### 2. Create a user and a pre-auth key
```bash
docker compose exec headscale headscale users create homelab
docker compose exec headscale headscale preauthkeys create --user homelab --expiration 1h
```
Copy the key it prints — you'll use it once on each device you join.
### 3. Connect the LAN node (the Docker host running Jellyfin)
On the **LAN node**, install the Tailscale client and point it at your Headscale server:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server=https://headscale.example.com --authkey <YOUR-PREAUTH-KEY>
```
(Arch users: `sudo pacman -S tailscale && sudo systemctl enable --now tailscaled`, then the same `tailscale up` command.)
### 4. Connect your phone / laptop
Install the Tailscale app (App Store / Play Store / https://tailscale.com/download). In the app, choose **Use a custom server / alternate coordination server** and enter `https://headscale.example.com`, or on desktop run the same `tailscale up --login-server=...` command. Generate a fresh pre-auth key per device, or approve interactively:
```bash
# if a device shows a registration URL instead of using a key:
docker compose exec headscale headscale nodes register --user homelab --key <KEY-FROM-DEVICE>
```
### 5. Verify connectivity
```bash
docker compose exec headscale 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.
### 6. Open Jellyfin
From your phone/laptop (with Tailscale connected), 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`)
Jellyfin's port 8096 only needs to be reachable on the LAN node itself — never forward it on your router.
### 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 compose exec headscale headscale nodes list-routes
docker compose exec headscale headscale nodes approve-routes -i <NODE-ID> --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 compose exec headscale headscale nodes list
docker compose exec headscale headscale nodes delete -i <NODE-ID>
docker compose exec headscale 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
HEADSCALE_LOG_LEVEL default: info
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:
```
./config/headscale/config → Contains config.yaml (required configuration file)
./data/headscale → SQLite database and persistent Headscale state
./logs/headscale → Optional logs for debugging and monitoring
${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.
Back up `data/` and `config/` regularly for recovery.
## Network Notes
Uses a private Docker bridge network: internal
No public ports exposed directly
All access is routed through reverse proxy (NPM)
Reverse Proxy Requirements (Nginx Proxy Manager)
* 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 ✔
For headscale.example:
Forward hostname: headscale
Forward port: 8080
Scheme: http
Enable:
Websockets Support ✔
Block Common Exploits ✔
SSL (Lets Encrypt) ✔
Disable caching ✔
Additional Notes / Gotchas
Headscale will not function without a valid config.yaml
server_url inside config.yaml MUST match your domain.
Clients must explicitly point to your server:
tailscale up --login-server=https://headscale.4lt.ca
Do NOT expose port 8080 publicly — only via reverse proxy
SQLite is used by default (no external DB required)
Dockhand Stack, Deploy from Git
Cookbooks Repository
stackname: headscale
Compose file path:
headscale/compose.yaml
Additional env file (optional):
headscale/sample.env
Deployment Steps
Load sample.env into Dockhand environment variables
Ensure config.yaml exists in:
./config/headscale/config/config.yaml
Deploy stack
Configure reverse proxy in Nginx Proxy Manager
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
@@ -96,14 +182,20 @@ docker run -d \
-v /data/headscale/config:/etc/headscale \
-v /data/headscale/data:/var/lib/headscale \
-v /data/headscale/logs:/var/log/headscale \
headscale/headscale:latest
headscale/headscale:latest \
headscale serve
```
See compose.yaml for the full set of environment variables.
## Additional Notes / Gotchas
Nothing specific to this stack so far.
* 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
@@ -112,32 +204,8 @@ 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
Then "Load" headscale_dev/sample.env into the Environmental variables in dockhand.
Create the Stack
Ensure `config.yaml` exists at `${VOL_PATH}/headscale/config/config.yaml` before deploying.
## Core Settings
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
CONFIG_PATH Location of configuration files (config.yaml)
DATA_PATH Persistent database storage (SQLite)
LOG_PATH Application logs
# System
TZ Timezone (e.g. America/Vancouver)
HEADSCALE_LOG_LEVEL Logging verbosity (info, debug, etc.)
Domain Configuration
HEADSCALE_DOMAIN Public-facing domain used via reverse proxy
Example: headscale.4lt.ca
HEADSCALE_URL Full URL used by Headscale clients
Must match your reverse proxy domain:
```
Create the Stack, then configure the reverse proxy in Nginx Proxy Manager.
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Headscale Quick Start — Zero to Jellyfin in 15 Minutes</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
h1 { font-size: 1.7rem; }
h2 { margin-top: 2.2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
pre { background: #8881; border: 1px solid #8883; border-radius: 6px; padding: .8rem 1rem; overflow-x: auto; }
code { font-family: ui-monospace, monospace; font-size: .92em; }
.diagram { text-align: left; }
.checklist li { margin: .4rem 0; }
.note { background: #7af3; border-left: 4px solid #47a; padding: .6rem 1rem; border-radius: 4px; margin: 1rem 0; }
.warn { background: #fa73; border-left: 4px solid #a74; padding: .6rem 1rem; border-radius: 4px; margin: 1rem 0; }
li { margin: .25rem 0; }
</style>
</head>
<body>
<h1>Headscale Quick Start</h1>
<p><strong>Goal:</strong> reach Jellyfin (and other containers) on your home LAN from anywhere,
without exposing anything to the internet. Zero to working in about 15 minutes.</p>
<h2>The setup</h2>
<pre class="diagram">
[Phone / Laptop]
|
Tailscale client
|
[Dedicated Server] ← public IP, runs Headscale (control plane only)
|
Tailscale network (WireGuard mesh)
|
[LAN Node] ← Docker host at 192.168.x.x, runs Jellyfin
</pre>
<p>The dedicated server only coordinates the mesh. Your media streams directly
between your device and the LAN node over encrypted WireGuard.</p>
<h2>Checklist</h2>
<ol class="checklist">
<li>☐ Install Headscale on the dedicated server</li>
<li>☐ Create a user</li>
<li>☐ Generate an auth key</li>
<li>☐ Join the LAN node (and your phone/laptop)</li>
<li>☐ Open Jellyfin at <code>http://jellyfin:8096</code> or the Tailscale IP</li>
</ol>
<h2>1. Install Headscale (dedicated server)</h2>
<p>From this repo's <code>headscale_dev/</code> directory:</p>
<pre><code>mkdir -p /data/headscale/config
cp config.yaml /data/headscale/config/config.yaml
nano /data/headscale/config/config.yaml # set server_url: https://headscale.example.com (YOUR domain)
cp sample.env .env
docker compose up -d</code></pre>
<p>Point your reverse proxy (Nginx Proxy Manager) at container <code>headscale</code>,
port <code>8080</code>, with SSL and websockets enabled. Then verify:</p>
<pre><code>curl https://headscale.example.com/health</code></pre>
<h2>2. Create a user</h2>
<pre><code>docker compose exec headscale headscale users create homelab</code></pre>
<h2>3. Generate an auth key</h2>
<pre><code>docker compose exec headscale headscale preauthkeys create --user homelab --expiration 1h</code></pre>
<p>Copy the key it prints.</p>
<h2>4. Join the LAN node</h2>
<p>On the LAN node (your Docker host running Jellyfin):</p>
<pre><code>curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server=https://headscale.example.com --authkey &lt;YOUR-PREAUTH-KEY&gt;</code></pre>
<p>On your phone: install the Tailscale app, choose <em>custom / alternate coordination server</em>,
and enter <code>https://headscale.example.com</code>. On a laptop, run the same
<code>tailscale up</code> command with a fresh key.</p>
<p>Verify from your laptop:</p>
<pre><code>tailscale status
tailscale ping &lt;lan-node-name&gt;</code></pre>
<h2>5. Open Jellyfin</h2>
<p>With Tailscale connected on your device, open:</p>
<ul>
<li><code>http://&lt;lan-node-tailscale-ip&gt;:8096</code> (the 100.64.x.x address from <code>tailscale status</code>), or</li>
<li><code>http://&lt;lan-node-name&gt;.tailnet.local:8096</code> via MagicDNS</li>
</ul>
<div class="note"><strong>Optional — reach the rest of your LAN:</strong> on the LAN node run
<code>sudo tailscale up --login-server=https://headscale.example.com --advertise-routes=192.168.1.0/24</code>,
enable IP forwarding, then approve the route with
<code>headscale nodes approve-routes</code> on the server. See the README for details.</div>
<div class="warn"><strong>Firewall:</strong> only 80/443 open on the dedicated server. Never forward
port 8096 (Jellyfin) or 8080 (Headscale) on your router. <br>
<strong>Lost a device?</strong> Cut it off instantly:
<code>docker compose exec headscale headscale nodes delete -i &lt;NODE-ID&gt;</code></div>
<p>Full walkthrough, subnet routing, and gotchas: see <code>README.md</code> in this directory.</p>
</body>
</html>