Files
Fizzlepoof 09232f7d70
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
Publish Headscale via Pangolin
2026-05-26 01:50:51 +00:00
..
2026-05-26 01:50:51 +00:00
2026-05-26 00:48:01 +00:00
2026-05-26 01:50:51 +00:00
2026-05-26 01:50:51 +00:00

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:
cd /mnt/docker-ssd/docker/compose/headscale
docker compose --env-file .env config
  1. Bring up Headscale first:
docker compose --env-file .env up -d headscale
docker logs headscale --tail=100
  1. Create pilot users:
docker exec -it headscale headscale users create fizzlepoof
docker exec -it headscale headscale users create manndra
docker exec -it headscale headscale users list
  1. Generate a Headscale API key for Headplane login:
docker exec -it headscale headscale apikeys create --expiration 90d

Save the returned key somewhere secure. Headplane uses it for the initial admin login.

  1. Enroll John's admin device.
  2. Bring up Headplane:
docker compose --env-file .env up -d headplane
docker logs headplane --tail=100
  1. Log in to Headplane with the API key from step 7.
  2. Register one tagged service node.
  3. 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:

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.