# Headscale pilot on PD Self-hosted Headscale + Headplane pilot stack for replacing the Tailscale free-tier 3-user limit without doing a blind cutover. ## Why this exists - remove the 3-human-user ceiling from the current Tailscale free-tier setup - keep the control plane on PD, the long-term primary Docker host - prove a small household-safe access model before expanding scope - keep the repo copy as the source of truth for stack config and policy ## Pilot shape - Host: **PlausibleDeniability** - Live path: `/mnt/docker-ssd/docker/compose/headscale` - Services: - `headscale` - `headplane` - Data paths: - `/mnt/docker-ssd/docker/appdata/headscale` - `/mnt/docker-ssd/docker/appdata/headplane` - No OIDC on day one - No subnet-router or exit-node rollout on day one - Headscale can be published later as a dedicated public control-plane hostname without exposing Headplane the same way ## URLs for the pilot - Headscale control plane for clients: `https://headscale.paccoco.com` - Headplane admin UI (restricted/LAN-only): `http://headplane.home.paccoco.com:3005/admin` This is the recommended **Option C** shape: - publish **Headscale** on a stable public HTTPS hostname so phones and laptops can enroll/use it off-LAN - keep **Headplane** off the public internet by default; treat it as an admin surface reached on LAN/private DNS (or a separately restricted admin path later) The internal Traefik example in `examples/traefik-routes.yml` is still useful for private-DNS/LAN convenience, but it is not the public control-plane path for off-LAN device enrollment. ### Current live pilot note Before the public hostname is wired, the live PD pilot uses direct-IP URLs: - Headscale: `http://10.5.30.6:8084` - Headplane: `http://10.5.30.6:3005/admin` Once `https://headscale.paccoco.com` is created and verified through Pangolin, off-LAN clients should use that public Headscale URL. Headplane can stay on the restricted LAN/private-DNS path above. ## Initial users - `fizzlepoof` - `manndra` Policy references them as: - `fizzlepoof@` - `manndra@` ## Initial tags - `tag:infra` - `tag:apps` - `tag:admin` ## Access model - `fizzlepoof@` = full pilot admin access to `tag:infra`, `tag:apps`, `tag:admin`, and his own devices - `manndra@` = app access only via `tag:apps` plus her own devices - no limited-user tier yet - no routed-LAN access yet ## Files - `docker-compose.yaml` - `.env.example` - `config/headscale/config.yaml` - `config/headscale/policy.hujson` - `config/headplane/config.yaml` - `examples/traefik-routes.yml` ## Pre-deploy edits Before first deploy, replace the placeholder values in: - `.env` copied from `.env.example` - `config/headplane/config.yaml` Required changes: - replace `CHANGE_ME_TO_EXACTLY_32_CHARS` with a real 32-character cookie secret - confirm the restricted Headplane LAN/private-DNS record exists if you want the nicer admin hostname - for off-LAN clients, create/verify the public Pangolin hostname for Headscale - if you want different ports or hostnames, update both the config files and `.env` Important live-ops note: - the repo-tracked `config/headplane/config.yaml` intentionally keeps a placeholder `cookie_secret` - before restarting the live Headplane container, render or restore the real 32-character secret from the live secret-bearing `.env` - blindly syncing the tracked placeholder over the live config will make Headplane fail startup with `server.cookie_secret must be exactly length 32` ## Deploy on PD 1. Sync this directory into the live compose tree: - `/mnt/docker-ssd/docker/compose/headscale` 2. Copy `.env.example` to `.env` 3. Create persistent appdata paths: - `/mnt/docker-ssd/docker/appdata/headscale` - `/mnt/docker-ssd/docker/appdata/headscale/run` - `/mnt/docker-ssd/docker/appdata/headplane` 4. Validate: ```bash cd /mnt/docker-ssd/docker/compose/headscale docker compose --env-file .env config ``` 5. Bring up Headscale first: ```bash docker compose --env-file .env up -d headscale docker logs headscale --tail=100 ``` 6. Create pilot users: ```bash docker exec -it headscale headscale users create fizzlepoof docker exec -it headscale headscale users create manndra docker exec -it headscale headscale users list ``` 7. Generate a Headscale API key for Headplane login: ```bash docker exec -it headscale headscale apikeys create --expiration 90d ``` Save the returned key somewhere secure. Headplane uses it for the initial admin login. 8. Enroll John's admin device. 9. Bring up Headplane: ```bash docker compose --env-file .env up -d headplane docker logs headplane --tail=100 ``` 10. Log in to Headplane with the API key from step 7. 11. Register one tagged service node. 12. Enroll one Manndra device. ## Public Headscale via Pangolin For the off-LAN/mobile path, publish only Headscale itself. Desired public hostname: - `https://headscale.paccoco.com` Desired Pangolin target shape: - resource name: `headscale` - site: `Plausible Deniability` (siteId `4`) - target: `headscale:8080` - healthcheck path: `/health` - Pangolin auth/SSO: **disabled** for this route Why: - Headscale clients need a plain reachable control-plane endpoint; adding browser SSO in front of it is the wrong shape - Headplane is an admin UI and should stay restricted/admin-only instead of being published like a normal household app Use `automation/bin/pangolin_upsert_headscale.py` to create or reconcile the public Pangolin resource. ## Policy reload After editing `config/headscale/policy.hujson`: ```bash docker exec -it headscale kill -HUP 1 ``` Then inspect the container logs for policy parse results. ## Owner-facing Headplane pilot checklist This section is for John as the stack owner using the Web UI. Doris should handle the CLI and backend admin work; this checklist is about what John should look for in Headplane and when to escalate. ### Where to go - Open: `http://headplane.home.paccoco.com:3005/admin` - Log in with the temporary Headscale API key Doris generated for the pilot ### What you should expect to see first After initial pilot bring-up, Headplane should show: - two users: - `fizzlepoof` - `manndra` - John's enrolled device(s) - one tagged pilot service node - a generally healthy/online control plane with no obvious UI errors If any of those are missing, that is a Doris problem to fix, not a John problem to debug. ### UI-first smoke test #### 1. Confirm the users exist Look for: - `fizzlepoof` - `manndra` Good: - both users appear once Bad / message Doris if: - a user is missing - a duplicate or unexpected user exists - user ownership looks wrong #### 2. Confirm John's device is present Look for at least one clearly identifiable John-owned device. Good: - device shows as online - device is owned by `fizzlepoof` - the name is recognizable enough to tell what it is Bad / message Doris if: - device is offline when it should be online - device is attached to the wrong user - device appears more than once unexpectedly #### 3. Confirm Manndra's device is present Good: - device is owned by `manndra` - it appears separately from John's devices Bad / message Doris if: - it shows under the wrong user - it never appears after enrollment - it appears to have broad access it should not have #### 4. Confirm the tagged node looks like a service node Look for one node tagged as a pilot service node, typically with `tag:apps` or `tag:infra`. Good: - the node is visibly tagged - it is not owned like a normal human personal device - it is online when the underlying service is online Bad / message Doris if: - the node is untagged - it appears owned like a personal device when it should be infra - it has the wrong tag #### 5. Confirm the UI is usable enough to keep Good: - pages load consistently - node/user details are understandable - no obvious blank/error screens - refreshing does not randomly lose state Bad / message Doris if: - login repeatedly fails with a known-good API key - pages partially load or spin forever - the UI looks disconnected from reality - the UI feels too broken to trust for visibility ### Expected access behavior John does not need to test raw ACL syntax. The practical expectations are: - `fizzlepoof` should have broad pilot visibility/access - `manndra` should be limited to app-level access - Manndra should **not** have broad admin/infra access by default If Manndra can reach something that feels like core infra/admin, treat that as a problem and tell Doris. ### What counts as pilot success from John's side The pilot is good enough to continue if: - Headplane reliably loads - the users/nodes make sense at a glance - John's devices are visible - Manndra's device is visible and separated correctly - the tagged service node is visible and clearly infra-like - nothing suggests accidental overexposure ### When John should call Doris instead of poking at it Call Doris if: - login stops working - users disappear or duplicate - devices are attached to the wrong owner - a tagged node loses its tag or looks wrong - Manndra appears to have too much access - the UI starts showing stale, contradictory, or obviously broken state The point of the Web UI is visibility and confidence, not pushing John into VPN-control-plane babysitting. ## What to validate before expanding - Headscale stays healthy - Headplane is usable enough to justify keeping it - John can reach `tag:infra`, `tag:admin`, and `tag:apps` - Manndra can reach `tag:apps` but not `tag:infra` or `tag:admin` - one tagged service node registers cleanly and behaves as expected - existing Tailscale remains intact as rollback during the pilot ## Notes on Headplane integration This stack enables Headplane's Docker integration so it can identify the Headscale container via the label: - `me.tale.headplane.target=headscale` It also mounts the tracked Headscale config into Headplane so the UI can inspect and, if you later choose, manage more than just node registration. ## Not done here on purpose - no OIDC wiring yet - no DERP customization yet - no public auth/proxy routing yet - no explicit subnet-router policy yet - no migration of the existing infrastructure stack's `tailscale` container yet Pilot first. Cutover later.