A self-contained stack: Headscale (Tailscale control
server), Headplane (its web UI), and Caddy for TLS —
both services on one hostname, dashboard at /admin, exactly how Tailscale's
own console is laid out.
setup.sh handles the part that makes this fiddly by hand: Headplane needs a
Headscale API key, but that key can only be created after Headscale is already
running. The script starts Headscale first, mints the key, drops it where
Headplane expects it, then brings everything up.
.
├── compose.yaml headscale + headplane + caddy
├── .env.example copy to .env and edit
├── setup.sh bootstrap / re-run any time
├── templates/ source of truth for the configs
│ ├── headscale.config.yaml
│ ├── headplane.config.yaml
│ ├── Caddyfile
│ └── package.compose.yaml shape of the --generate output
├── config/ rendered by setup.sh — edit freely
├── secrets/ cookie secret + API key (git-ignored)
└── dist/ --generate output (git-ignored)
Point an A/AAAA record at the host first, and make sure 80 and 443 are open — Caddy needs both to get a certificate.
chmod +x setup.sh
cp .env.example .env
$EDITOR .env # HS_DOMAIN, HS_PUBLIC_URL, HP_PUBLIC_URL,
# ACME_EMAIL, HS_BASE_DOMAIN
./setup.shThe script prints the dashboard URL and an API key. Paste that key into the
login page at https://your-domain/admin.
Then join a node:
tailscale up --login-server https://your-domainApprove it in the dashboard, or from the CLI:
docker compose exec headscale headscale nodes listSkip Caddy and run plain HTTP:
COMPOSE_PROFILES=
BIND_ADDR=0.0.0.0
HS_PUBLIC_URL=http://192.168.1.50:8080
HP_PUBLIC_URL=http://192.168.1.50:3000
HP_COOKIE_SECURE=false
HS_BASE_DOMAIN=tail.internalHeadscale at :8080, Headplane at :3000/admin. HP_COOKIE_SECURE=false is
mandatory over HTTP — otherwise the browser discards the session cookie and
login fails without an error message. setup.sh refuses to proceed if you get
that combination wrong.
HS_BASE_DOMAIN must differ from your Headscale hostname. Headscale
rejects the config outright if they match. If Headscale lives at
headscale.example.com, use something like tail.example.com for MagicDNS.
Both containers mount config/headscale/config.yaml at the same path.
That is not incidental — Headplane resolves the path from Headscale's own
config, so a mismatch breaks the DNS and Settings pages. Keep them in sync if
you move things around.
The me.tale.headplane.target label is load-bearing. It's how Headplane
finds the container to signal after you change settings in the UI. Removing it
leaves those settings read-only.
Headplane gets the Docker socket read-only. It needs it to restart
Headscale. If that's more trust than you want to extend, put
docker-socket-proxy in
front and point integration.docker.socket at it over tcp://.
Secrets are mode 644 inside a mode 700 directory. The Headplane image is
distroless and runs as a non-root user whose UID won't match yours, so the
files have to be world-readable to the container. The directory permission is
what actually keeps other host users out. Same reasoning for chmod 666 on the
Headscale config, which Headplane needs to write.
ACLs live in the database (policy.mode: database), so Headplane edits
them over the API with no restart. Prefer them in git? Switch to
mode: file + path: /etc/headscale/acl.hujson and mount that file into both
containers, read-write, the same way config.yaml is mounted.
./setup.sh --generate reads the same .env and the same templates/, but
instead of deploying it writes one self-contained file to
dist/docker-compose.yml. Every config — Headscale's config.yaml, Headplane's
config.yaml, the Caddyfile, the session secret — is embedded as a
configs: content: block. Nothing to upload alongside it.
./setup.sh --generate
# → dist/docker-compose.ymlThe output is a build artifact, not a source file: re-run --generate after
any change to .env or templates/, and redeploy. A stale file is the single
most likely cause of a container that won't start.
Paste it into Portainer / Coolify / Dokploy / Komodo, or docker compose up -d.
If a local Docker is present the generator validates the output with
docker compose config before writing, so a broken file fails on your laptop
rather than on the server.
The terminal-based flow uses docker compose exec to mint the API key. The
packaged flow doesn't need a Docker socket for that at all — the Headscale CLI
talks to the running server over the unix socket in the headscale-run volume,
which every service on the network can already mount. So key-init is just the
Headscale image again, pointed at its own server:
| Service | Image | Job |
|---|---|---|
config-init |
alpine |
Copies the embedded configs onto writable volumes, sets modes, exits. Needed because configs mount read-only, but Headplane must write Headscale's config for the DNS/Settings pages. |
key-init |
same as headscale |
headscale apikeys create over the shared unix socket. No Docker socket, no shell. Prints the key to its log. |
Ordering comes from Headscale's own healthcheck (headscale health, 0.27+)
rather than a hand-rolled wait loop:
config-init ──completed──▶ headscale ──healthy──▶ key-init
└──completed(config-init)──▶ headplane ──▶ caddy
key-init prints the login key to its own log — that's how you retrieve it
without a shell:
docker compose logs key-initOr open that container's log in your platform's UI.
Headplane deliberately does not wait on key-init. The key is only needed for
OIDC and the Headplane agent, and the generator omits api_key_path entirely
unless a key is actually available to seed — pointing that setting at a file
nothing creates crash-loops the UI.
Two passes, no terminal needed:
- Deploy as normal. Read the key from
key-init's log. - Paste it into
.envasHEADSCALE_API_KEY=hskey-api-..., re-run./setup.sh --generate, redeploy.
The second pass embeds the key as its own configs: entry, has config-init
seed it onto the volume, and then emits api_key_path. The config and the
file it points at are generated together, so they can't disagree.
After the above, the only remaining Docker socket is Headplane's, read-only, used to signal Headscale after a settings change. If your platform forbids socket mounts entirely:
HP_DOCKER_INTEGRATION=falseThe generator then omits the mount and sets integration.docker.enabled: false so the two can't disagree. Everything still works except the DNS and
Settings pages, which go read-only — edit templates/headscale.config.yaml and
regenerate instead.
Both init services are idempotent. config-init will not overwrite
/etc/headscale/config.yaml if it already exists, so settings you changed
through the Headplane UI survive a redeploy. key-init exits immediately if a
key is already on the volume. Wiping the volumes is what resets things.
- Needs a reasonably current Compose (the
configs: content:field, ~v2.23+). Every mainstream platform ships something newer. - The healthcheck gate requires Headscale 0.27 or newer. On an older pin,
headscale healthdoesn't exist, sokey-initwould wait forever. - The generated file contains the session secret. It's written mode 600 and
dist/is git-ignored. Treat it like a credential. key-initmints a key each time it's recreated, so redeploys leave unused keys behind. Expire them in the dashboard, or drop the service once you're in.- Regenerate rather than hand-edit the output. Change
.envortemplates/, re-run--generate.
Default flow: admin creates a user, admin mints a pre-auth key, hands over a
tailscale up --authkey ... command. Admin is in the loop for every device.
Point Headscale at your IdP and that disappears:
tailscale up --login-server https://hs.example.comA browser opens, the user authenticates, the node registers. Headscale creates the user from the IdP claims on first login. No pre-auth key, no admin step — per user or per device.
"Admin adds the users" becomes "admin grants access in the IdP". Set it up with:
OIDC_ISSUER=https://accounts.google.com
OIDC_CLIENT_ID=....apps.googleusercontent.com
OIDC_CLIENT_SECRET=...
OIDC_ALLOWED_DOMAINS=example.comOne OIDC_ISSUER configures both Headscale and Headplane off the same
client, which is the recommended arrangement: the sub claim then matches on
both sides, so Headplane links users to their Headscale identity automatically.
Register two redirect URIs on that one client:
https://hs.example.com/oidc/callback <- Headscale
https://hs.example.com/admin/oidc/callback <- Headplane
Both route correctly through the bundled Caddyfile with no changes.
allowed_domains/allowed_groups/allowed_usersis the only gate. Headscale has no device-approval queue. Anyone who passes that filter enrols unlimited devices unattended, which is the point — but scope it to a group rather than a whole domain if you want a tighter boundary. The generator warns if you set none of the three.- Write an ACL policy. The default is allow-all. Once enrolment is
self-service, every user's devices can reach every other user's. Policy mode
is
database, so the Headplane ACL editor is the place to do it. - Headless servers can't do a browser flow. Keep pre-auth keys for those,
ideally with
--advertise-tagsandtagOwnersin your ACL — tagged nodes don't expire and aren't tied to a person who might leave. - Existing nodes don't migrate. OIDC users are keyed on the IdP subject, so
they won't match the local
adminuser. Existing nodes keep working under it; new enrolments land on IdP-backed users. Re-register to consolidate. - Node expiry still applies (
node.expiry, default 180d) — users re-auth in the browser periodically.use_expiry_from_tokenis off, otherwise expiry follows your IdP's token lifetime and users re-auth constantly.
Google Workspace — OAuth 2.0 Client ID, type Web application. Set the
consent screen to Internal; that, not allowed_domains, is the real
enforcement boundary. Google doesn't emit a groups claim without Admin SDK
work, so OIDC_ALLOWED_GROUPS won't work — use domains.
Microsoft 365 / Entra ID — register a single-tenant app; that's the
restriction. Use the tenant-specific issuer
https://login.microsoftonline.com/<tenant-id>/v2.0, not common. Entra can
emit group object IDs for OIDC_ALLOWED_GROUPS once you add the groups claim.
OIDC needs HEADSCALE_API_KEY, so the packaged build enforces the two-pass
flow (--generate refuses without it, with an explanation). And do not set
HP_DISABLE_API_KEY_LOGIN=true until SSO is verified working — it's your only
way back in if the IdP config is wrong.
Sign in yourself first. The first OIDC user to reach Headplane is permanently granted Owner.
docker compose logs -f headplane # why won't it start
docker compose ps # health status
docker compose pull && docker compose up -d # upgrade
./setup.sh --force # re-render config/ from templates/Mint a short-lived key for someone else to log in with:
docker compose exec headscale headscale apikeys create --expiration 24hRotate the key Headplane itself uses:
rm secrets/headscale_api_key && ./setup.sh
docker compose restart headplaneHeadscale does not support skipping minor versions — go 0.27.x →
0.28.x → 0.29.x, taking the newest patch of each. Back up the database
first:
docker compose stop headscale
docker run --rm -v headscale-stack_headscale-data:/data -v "$PWD:/backup" \
alpine cp /data/db.sqlite /backup/db.sqlite.bak
# bump HEADSCALE_TAG in .env
docker compose up -d headscaleCheck the changelog for breaking changes before each hop.
| Symptom | Cause |
|---|---|
| Login page accepts the key, then bounces back | cookie_secure: true over plain HTTP, or server.base_url doesn't match the URL in your address bar |
| Headplane container restarts on loop | secrets/headscale_api_key or secrets/cookie_secret missing — run ./setup.sh |
| DNS / Settings pages are read-only | Headplane can't write config/headscale/config.yaml, or the me.tale.headplane.target label is gone |
| Headplane won't load, complains about config | Version skew between Headscale and Headplane; config_strict is already false, so check that Headplane supports your HEADSCALE_TAG |
| Nodes connect but can't reach each other | Expected without a DERP relay if both are behind strict NAT — enable derp.server.enabled and publish 3478/udp |
| Headplane: "secret file ... could not be accessed" | Deploying a stale generated file. grep api_key_path dist/docker-compose.yml — if it matches but HEADSCALE_API_KEY is empty in .env, re-run --generate |
key-init never runs |
Headscale isn't reporting healthy — check docker compose logs headscale, and that HEADSCALE_TAG is 0.27+ |
| Caddy can't get a certificate | DNS not pointing here yet, or something else already holds port 80 |
The Headplane agent pulls OS/version info from nodes and powers in-browser SSH. It needs a dedicated Headscale user:
docker compose exec headscale headscale users create headplane-agentThen in config/headplane/config.yaml:
integration:
agent:
enabled: true
host_name: "headplane-agent"Restart Headplane. Browser SSH additionally requires nodes running
tailscale up --ssh. Details: https://headplane.net/features/agent
Headplane supports any OIDC provider. Use the same client ID as Headscale so
identities line up, put the client secret in
secrets/oidc_client_secret, and uncomment the oidc block in
config/headplane/config.yaml. Setup notes per provider:
https://headplane.net/features/sso