# headscale [Headscale](https://headscale.net) — a self-hosted Tailscale control server — behind Caddy, with OIDC login delegated to [pocket-id](../pocket-id/). ## Prerequisite Register an OIDC client in pocket-id's admin UI with redirect URI: ``` https://${HEADSCALE_DOMAIN}/oidc/callback ``` Then paste its `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` below. ## Required `.env` values | Variable | Notes | |----------|-------| | `HEADSCALE_DOMAIN` | Public hostname (e.g. `hs.example.com`). Clients connect here over HTTPS. | | `ACME_EMAIL` | Let's Encrypt registration email. | | `TAILNET_DOMAIN` | MagicDNS suffix (e.g. `tail.example.com`). Must differ from `HEADSCALE_DOMAIN`. | | `POCKETID_DOMAIN` | OIDC issuer hostname (your pocket-id). | | `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | From the pocket-id client above. | The deploy substitutes these into `config.yaml` from the embedded template. See [`.env.example`](.env.example). ## Deploy ```bash ./automations.sh # Deploy on this host → deploy: headscale ``` Or build + run the self-contained artifact: ```bash ./build.sh scp deploy.sh root@host: ssh root@host 'bash deploy.sh' # non-interactive: pass HEADSCALE_DOMAIN, ACME_EMAIL, TAILNET_DOMAIN, # POCKETID_DOMAIN, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, SKIP_PROMPTS=1 ``` Unattended provisioning: [`cloud-init.yml`](cloud-init.yml). DNS for `HEADSCALE_DOMAIN` must resolve to the host and 80/443 be reachable before deploy. ## Headplane (web UI) [Headplane](https://github.com/tale/headplane) ships in the stack, served by Caddy at **`https://HEADSCALE_DOMAIN/admin`**. It runs **API-only** — it talks to the headscale API with a key and does *not* get the Docker socket, so it can't control the host. `deploy.sh` mints the headscale API key automatically on first deploy (stored in `.env`) and generates `headplane.yaml`. **Login:** to use OIDC via pocket-id, register a **second** OIDC client in pocket-id with redirect URI: ``` https://HEADSCALE_DOMAIN/admin/oidc/callback ``` and pass `HEADPLANE_OIDC_CLIENT_ID` / `HEADPLANE_OIDC_CLIENT_SECRET` (prompted by the launcher, or set in `.env`). Leave them blank to use headplane's API-key login instead. Pin the image with `HEADPLANE_TAG` (defaults to `latest`). **Gate `/admin` to a superuser group:** restrict it at pocket-id — edit the headplane OIDC client and set its **Allowed User Groups** to your superuser group. Only members of that group can complete the OIDC login, so only they can reach `/admin`. (Enforced at the IdP, so it covers the UI regardless of headplane's own checks.) > headplane integration is version-sensitive; if the UI doesn't come up, > check `docker compose logs headplane` and verify `headplane.yaml` / > the minted `HEADPLANE_HS_API_KEY` in `.env`. ## ACL policy A starter ACL ships in [`policy.hujson`](policy.hujson) (installed to `$STACK_DIR/policy.hujson` on first deploy; your edits survive re-deploys). **headscale uses Tailscale's legacy `acls` format, not the newer `grants` syntax** from the Tailscale admin console — the bundled policy is that default translated over (per-user self-access, a `tag:shared` everyone can reach, and SSH to your own devices). Some pieces (`autogroup:self`, the `ssh` block) are newer/experimental in headscale; a permissive fallback is included in the file. ```bash $EDITOR /srv/headscale/policy.hujson headscale policy check # validate (host CLI wrapper) cd /srv/headscale && docker compose restart headscale # apply ```