Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Headscale + Headplane via Docker Compose

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.

Layout

.
├── 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)

Quickstart

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.sh

The 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-domain

Approve it in the dashboard, or from the CLI:

docker compose exec headscale headscale nodes list

Without a domain (LAN / testing)

Skip 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.internal

Headscale 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.

Things worth knowing

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.

Packaging it for a server with no terminal

./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.yml

The 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.

How the bootstrap moves inside the stack

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-init

Or 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.

Turning on OIDC or the agent

Two passes, no terminal needed:

  1. Deploy as normal. Read the key from key-init's log.
  2. Paste it into .env as HEADSCALE_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.

Going completely socket-free

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=false

The 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.

Re-deploying is safe

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.

Caveats

  • 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 health doesn't exist, so key-init would 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-init mints 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 .env or templates/, re-run --generate.

Self-service device enrolment

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.com

A 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.com

One 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.

Read this before enabling it

  • allowed_domains / allowed_groups / allowed_users is 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-tags and tagOwners in 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 admin user. 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_token is off, otherwise expiry follows your IdP's token lifetime and users re-auth constantly.

Provider notes

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.

Order of operations

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.

Common operations

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 24h

Rotate the key Headplane itself uses:

rm secrets/headscale_api_key && ./setup.sh
docker compose restart headplane

Upgrading Headscale

Headscale does not support skipping minor versions — go 0.27.x0.28.x0.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 headscale

Check the changelog for breaking changes before each hop.

Troubleshooting

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

Optional: browser SSH and node metadata

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-agent

Then 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

Optional: single sign-on

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages