Caption: Browser to Caddy on 443, Headplane on loopback 3000 at /admin, Headscale HTTP API on 8080. I almost opened :3000 on the public IPv4 and left cookie_secure at the HTTPS default.
Why I wanted this on my server
I already treat Headscale as the control plane I would rather run than another SaaS mesh. The CLI works. headscale nodes list works. Pre-auth keys work. What does not work is inviting a second person and then walking them through docker exec every time a laptop needs a tag, an ACL line, or a MagicDNS record. I do not want them on the VPS. I want a browser tab that talks to the same API I already trust.
Self-Host Weekly put Headplane in the 9 Oct 2026 spotlight as that tab: Docker or bare metal, machines, users, ACLs, DNS, settings, browser SSH, OIDC. AGPL-3.0. It is an admin UI for Headscale, not a second VPN. I already wrote up Authentik and lldap. Those are identity. This write-up is the Headscale console. If I smear OIDC, a directory, and a mesh UI into one post, I get three failure domains and I still have not practiced restoring /var/lib/headplane.
v0.7.1 (28 Aug 2026) is the release I would pin. Release notes say 0.7.x works with Headscale 0.29 and requires 0.27 or newer. Official Docker docs on headplane.net still show headscale/headscale:0.26.0. That pin is the first wall. I am writing this as a lab log on Ubuntu 24.04. I did not stand the stack up in this Cloud Agent run. The compose, config keys, and failure shapes come from the official Docker guide, config.example.yaml, the 0.7.0 / 0.7.1 notes, Headscale's container docs, and the changelog that replaced OpenAPI fingerprinting with /version. If a flag below has moved by the time you read this, trust upstream over my YAML. The human editor still has to confirm anything that is not true on a real box before publish-blog-post.php --publish.
What I actually installed
Ubuntu 24.04 LTS, Docker Engine with the Compose v2 plugin. Images ghcr.io/tale/headplane:0.7.1 and headscale/headscale:0.29.3. Project under /opt/headplane. Headplane bound to 127.0.0.1:3000. Headscale bound to 127.0.0.1:8080. Caddy on 80/443 for https://headscale.example.com — replace that hostname. Nginx is the same idea: terminate TLS, send /admin to Headplane and everything else to Headscale, do not publish 3000 or 8080 on 0.0.0.0.
Official Headplane compose publishes "3000:3000". I tighten that to loopback myself. An admin UI that can mint pre-auth keys and rewrite ACLs is still a control plane. It is not a status page.
Hardware is not the story. Two containers. SQLite for Headscale. Headplane's own data under /var/lib/headplane. A 1 GB VPS can run this if I am not also importing a photo library the same weekend. I would not put Headscale and a fat CI runner on a 512 MB toy and then blame the UI.
I keep config.yaml mode 600. server.cookie_secret lives there. Later an API key may live in headscale.api_key if I turn on the agent or OIDC. I do not push that file to a public remote. If I must commit compose, I commit placeholders.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
UFW is allow-list. I do not add 3000. I do not add 8080. Headscale's HTTP API is what Tailscale clients and the admin UI call. Publishing it on the public IPv4 "so phones can join" is how I skip the proxy and skip TLS. Clients should see https://headscale.example.com. Headplane should see http://headscale:8080 on the Compose network.
If something already owns 80 and 443, I reuse that edge. A second proxy fighting for those ports looks like Headplane is broken when it is not. I check ss before I write a new Caddyfile.
ss -tlnp | grep -E ':80|:443|:3000|:8080'
Where it broke
On a fresh Ubuntu 24.04 box this install is famous for a UI that comes up and a session that does not.
I opened http://203.0.113.10:3000/ because that is what every other Compose app taught me. Blank or a redirect I did not expect. The UI is /admin, not /. Docs say it twice: Docker and native mode. Tailscale's own console is /admin on the same host. I keep doing the root-path habit anyway.
Second wall: I landed on /admin, pasted an API key, hit login, and bounced straight back to the form. Experience notes I keep for this app: server.cookie_secure defaults true. Browsers will not store a Secure cookie on http://IP:3000. The container is healthy. The cookie flag is the bug.
# The container is healthy. The cookie is the bug.
cookie_secure: true
# browser is on http://203.0.113.10:3000/admin
config.example.yaml is blunt. False if you are on plain HTTP. True behind a reverse proxy with HTTPS. When I later put Caddy in front I flip it back to true and set server.base_url to the URL I actually open, without /admin. Leave base_url: http://localhost:3000 behind https://headscale.example.com and callbacks, cookies, and the bits that print a public URL stay on localhost.
Third: cookie_secret: "<change_me_to_something_secure!>" is 31 characters if I count the brackets wrong, and it is not 32 if I paste a hex string from openssl rand -hex 32 (that is 64). The comment says must be 32 characters. openssl rand -hex 16 is the length that matches. A secret that is the wrong length fails closed. I treated the placeholder as something I would rotate later. Later never came before the first hostname.
Fourth: I copied the official Docker compose and left headscale/headscale:0.26.0. Headplane 0.7.0 dropped 0.26.x. Changelog: if /version returns 404 (the endpoint arrived in Headscale 0.27.0), Headplane logs an error and keeps retrying so an in-place upgrade is picked up. The UI looks "almost up." Logs look like a flaky network. It is a version pin. I pin headscale/headscale:0.29.3 next to ghcr.io/tale/headplane:0.7.1.
# Headplane 0.7.x talking to Headscale 0.26.0
GET /version -> 404
# Headplane retries. The admin UI never settles.
Fifth: headscale.url in the example is http://headscale:5000. Headscale's HTTP listen is 8080 (listen_addr). 5000 is not the API. I pointed Headplane at 5000, the container started, and every API call hung. Inside Docker I also have to set Headscale listen_addr: 0.0.0.0:8080. The example defaults to 127.0.0.1:8080, which is correct on the host and silent-dead from another container on the Compose network.
Sixth: I wanted DNS and Settings in the navbar. Those edits need Docker integration: integration.docker.enabled: true, the Headscale config mounted at the same host path and the same dest path in both containers, and label me.tale.headplane.target: headscale. Docs shout about the shared path. I mounted Headplane at /etc/headscale/config.yaml and Headscale at /etc/headscale as a directory and wondered why writes never landed. Persist /var/lib/headplane or the internal DB vanishes on recreate.
Seventh: official compose publishes 3000 and, for advanced features, mounts /var/run/docker.sock. I do not publish :3000 plus docker.sock on a public VPS. The socket is a root-equivalent API. The UI is a session cookie. Both stay behind loopback and TLS.
docker compose logs --tail=80 headplane
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/admin
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/version
ss -tlnp | grep -E '3000|8080'
If localhost /admin answers and HTTPS does not, the proxy or DNS is the problem, not Headplane. If /version is 404, the Headscale tag is the problem, not the cookie. If 3000 is on 0.0.0.0, the compose publish is the problem, not the login button.
I treat those as one chain. /admin is first because I stare at / and think the image is empty. cookie_secure is second because login "works" and the session dies. Wrong-length cookie_secret is third because the process may refuse the config. Headscale 0.26.0 is fourth because the UI retries forever. :5000 vs :8080 is fifth because the URL looks official. Shared paths and the label are sixth because DNS edits look like a permissions bug. Public 3000 plus the socket is last because it fails as an incident, not a stack trace.
The session that will not stick is the one I would waste an hour on. I would rotate the API key, blame the browser, then notice I was still on HTTP with cookie_secure: true. curl -I on /admin after login is faster than rereading the feature list.
Caption: Pin 0.29.3, 32-character cookie_secret, UI on loopback 3000 at /admin. The red branch is Headscale 0.26.0 and /version 404. The yellow branch is cookie_secure on plain HTTP.
The working install
Official Docker shape for Headplane plus Headscale, then Caddy. I followed upstream for image names and config keys, then bound both UIs/APIs to loopback and replaced the 0.26.0 pin. If docker compose version fails after the Engine install, I stop.
1. Install Docker Engine
sudo apt-get update -qqy
sudo apt-get install ca-certificates curl -qqy
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qqy
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin -qqy
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version
If docker compose version fails, I fix Docker before I write YAML. I log out and back in (or newgrp docker) so the socket group applies. Otherwise every compose command wants sudo and I end up with root-owned files under /opt/headplane.
2. Create the project directory and the 32-character secret
sudo mkdir -p /opt/headplane/headplane-data \
/opt/headscale/config \
/opt/headscale/data
sudo chown -R "$USER":"$USER" /opt/headplane /opt/headscale
cd /opt/headplane
# Exactly 32 characters. hex 32 is 64 — too long.
openssl rand -hex 16
I download Headscale's example for the tag I will run, not main:
curl -fsSL \
https://raw.githubusercontent.com/juanfont/headscale/v0.29.3/config-example.yaml \
-o /opt/headscale/config/config.yaml
# Empty extra-records file so the bind is a file, not a surprise directory.
echo '[]' > /opt/headscale/config/extra_records.json
chmod 600 /opt/headscale/config/config.yaml
Then I edit three Headscale fields before the first up. server_url is the public HTTPS name clients use. listen_addr is 0.0.0.0:8080 so Headplane on the Compose network can connect. dns.base_domain must be a different FQDN than server_url — Headscale refuses them equal.
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
dns:
magic_dns: true
base_domain: tailnet.example.com
I leave Headscale TLS empty and let Caddy terminate. I do not turn on the embedded DERP on day one. I want a login, a node list, and a restore I have actually run.
3. Write Headplane config and Compose
# /opt/headplane/config.yaml
server:
host: "0.0.0.0"
port: 3000
base_url: "https://headscale.example.com"
cookie_secret: "replace_with_openssl_rand_hex16"
cookie_secure: true
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale:8080"
public_url: "https://headscale.example.com"
config_path: "/etc/headscale/config.yaml"
dns_records_path: "/etc/headscale/extra_records.json"
integration:
docker:
enabled: true
container_label: "me.tale.headplane.target=headscale"
socket: "unix:///var/run/docker.sock"
cookie_secure: true matches Caddy on 443. If I test on an SSH tunnel to http://127.0.0.1:3000/admin first, I set it false for that hour and flip it before DNS goes live. I do not leave it false on the public hostname.
# /opt/headplane/docker-compose.yml
services:
headplane:
image: ghcr.io/tale/headplane:0.7.1
container_name: headplane
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./config.yaml:/etc/headplane/config.yaml:ro
- ./headplane-data:/var/lib/headplane
- /opt/headscale/config/config.yaml:/etc/headscale/config.yaml
- /opt/headscale/config/extra_records.json:/etc/headscale/extra_records.json
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- headscale
headscale:
image: headscale/headscale:0.29.3
container_name: headscale
restart: unless-stopped
command: serve
labels:
me.tale.headplane.target: headscale
ports:
- "127.0.0.1:8080:8080"
volumes:
- /opt/headscale/config/config.yaml:/etc/headscale/config.yaml
- /opt/headscale/config/extra_records.json:/etc/headscale/extra_records.json
- /opt/headscale/data:/var/lib/headscale
tmpfs:
- /var/run/headscale
The dest path for the Headscale config is the same string on both services. That is the rule the Docker docs repeat. I do not bind a missing config.yaml as a file — Docker turns a missing file bind into a directory and neither process starts cleanly. Same class of bug FileBrowser Quantum is famous for. I test -f the YAML before up.
chmod 600 /opt/headplane/config.yaml /opt/headplane/docker-compose.yml
test -f /opt/headplane/config.yaml
test -f /opt/headscale/config/config.yaml
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=80
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/admin
curl -sS http://127.0.0.1:8080/version
ss -tlnp | grep -E '3000|8080'
3000 and 8080 should be 127.0.0.1. /version should return JSON, not 404. Headplane's image ships /bin/hp_healthcheck; docker compose ps should show healthy after the start period.
4. Mint an API key, then log in at /admin
Headscale needs a user before I treat the mesh as real. Login to Headplane is a Headscale API key, not a local admin password.
docker exec headscale headscale users create default
docker exec headscale headscale apikeys create --expiration 90d
Open http://127.0.0.1:3000/admin over an SSH tunnel if DNS is not ready:
ssh -L 3000:127.0.0.1:3000 user@your-vps
Paste the key. If the form comes back empty, I check cookie_secure against the scheme I actually used. I do not mint a second key yet. I do not enable OIDC on day one. The first owner in OIDC mode is a different bootstrap; headscale.api_key becomes required for the agent and for SSO. Day one is API-key login, one user, one node I register from a laptop with tailscale up --login-server https://headscale.example.com.
If DNS/Settings are missing from the navbar, I did not get Docker integration. I check the label, integration.docker.enabled, and that both containers see the same /etc/headscale/config.yaml. docker inspect headscale --format '{{json .Config.Labels}}' is faster than guessing.
Caption: One host file, mounted at /etc/headscale/config.yaml in both containers. Label me.tale.headplane.target=headscale. docker.sock read-only. Different dest paths is the discarded branch.
5. Caddy for HTTPS
Create a Caddyfile beside Compose. Caddy obtains and renews Let's Encrypt when DNS already points at the server. Same origin: /admin to Headplane, everything else to Headscale. Same-origin means I can skip the Traefik CORS block official docs require when the UI and the API are split across hosts.
headscale.example.com {
handle /admin* {
reverse_proxy 127.0.0.1:3000
}
handle {
reverse_proxy 127.0.0.1:8080
}
}
If Caddy already runs on the host, I add that site block and I keep this project's 3000 and 8080 on loopback. If I am on Nginx instead, native-mode docs are the snippet I copy: location / to http://localhost:8080/, location /admin/ to http://localhost:3000, WebSocket upgrade headers on both. I do not invent a custom prefix. Official warning: /admin is the only supported path unless I rebuild with __INTERNAL_PREFIX.
Confirm DNS before the first TLS start. If issuance fails, I wait on DNS — I do not rewrite the Caddyfile hoping Let's Encrypt will guess. After the cert lands I open https://headscale.example.com/admin, log in, log out, log in. If I test only on the SSH tunnel, I have not tested the hostname server.base_url will print.
Behind Traefik, official Docker docs add a CORS middleware on the Headscale router. Skip that and the admin UI cannot call the API. I am on Caddy here. If I later split admin.example.com from headscale.example.com, I add the CORS headers then, not before.
Caption: UFW 80/443, TLS at the proxy, Headplane on 127.0.0.1:3000, Headscale on 127.0.0.1:8080. The discarded path is 3000:3000 plus docker.sock on a public VPS.
Useful to know
Official Docker docs still list Headscale 0.26.0 as a prerequisite. 0.7.0 made that stale. I treat the release notes as the pin, not the install page, until that page moves.
headscale.url is the HTTP API Headplane calls, not the gRPC port, not the public name. public_url is what the UI shows people. I keep them different on purpose: http://headscale:8080 internally, https://headscale.example.com on the card.
Do not enable integration.docker and integration.proc together. Example config says only one of those at a time. Proc is native mode, when both processes run on the host and Headplane can signal Headscale. Docker is this post.
Browser SSH is a later day. It needs the Headplane agent, Tailscale SSH on the nodes (tailscale up --ssh), and WebSocket through the proxy. I do not turn that on before I have a backup of /var/lib/headplane and /opt/headscale/data. Same for OIDC. Authentik can sit in front later. Day one is an API key I can revoke with headscale apikeys expire.
Headscale 0.29.3 wants Tailscale clients v1.80.0 or newer. An old Android build that "used to work" on 0.26 is a client floor, not a Headplane bug.
A file bind for a missing config.yaml becomes a directory. I create the file first. I have already written that sentence for another app this year. I will write it again the next time I skip test -f.
Backup, expose, next step
Compose is not a backup. /var/lib/headplane holds Headplane's DB. /opt/headscale/data holds db.sqlite and the Noise key. cookie_secret lives in config.yaml. Lose the Headscale volume and nodes vanish. Lose the cookie secret and every session dies; if I also stored an API key in the file, I need that string back too. I tar both trees and the YAML. I copy compose, because six months later I will not remember that 3000 was meant to stay on loopback.
I stop the stack before the tar so SQLite is not mid-write. A control plane nobody is joining at 3am is cheap to pause. A backup that only lives next to the volume is a snapshot, not an offsite copy.
cat > /opt/headplane/backup.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT="/opt/headplane"
HS_DATA="/opt/headscale"
BACKUP_DIR="$PROJECT/backups"
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cd "$PROJECT"
docker compose stop
mkdir -p "$BACKUP_DIR/headplane-config-${stamp}"
cp -a docker-compose.yml config.yaml "$BACKUP_DIR/headplane-config-${stamp}/"
[ -f Caddyfile ] && cp -a Caddyfile "$BACKUP_DIR/headplane-config-${stamp}/"
cp -a "$HS_DATA/config" "$BACKUP_DIR/headplane-config-${stamp}/headscale-config"
tar czf "$BACKUP_DIR/headplane-data-${stamp}.tar.gz" -C "$PROJECT/headplane-data" .
tar czf "$BACKUP_DIR/headscale-data-${stamp}.tar.gz" -C "$HS_DATA/data" .
docker compose start
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
echo "[$stamp] backup done under $BACKUP_DIR"
EOF
chmod 700 /opt/headplane/backup.sh
/opt/headplane/backup.sh
rsync -avz /opt/headplane/backups/ backup-user@backup.example.net:/srv/backups/headplane/
Restore on a spare VM: fresh compose, restore both data dirs, copy config.yaml with the same cookie_secret, start, log in with a new API key (apikeys create — the old key is in Headscale's DB if the volume came back). If login fails after a restore, I almost always copied the databases and regenerated the cookie secret, or I left cookie_secure true on HTTP. docker volume ls if I later switch the bind to a named volume.
Upgrades after a backup:
cd /opt/headplane
/opt/headplane/backup.sh
# Edit image tags, then:
docker compose pull
docker compose up -d
docker compose logs --tail=80
curl -sS http://127.0.0.1:8080/version
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/admin
If I floated :latest on Headplane and a Tuesday image changes auth, I want the tar from five minutes earlier. Pinning 0.7.1 next to 0.29.3 makes that Tuesday quieter. Do not climb Headscale past what the Headplane tag documents. 0.7.1 targets 0.29.x.
What I have running now, on paper for this lab, is Headplane 0.7.1 on loopback 3000, Headscale 0.29.3 on loopback 8080, Caddy on 443 for https://headscale.example.com/admin, shared config at /etc/headscale/config.yaml, Docker integration on, cookie_secure true, cookie_secret 32 hex chars, and a backup script I would actually execute before I invite a second node. Next I register one laptop with tailscale up --login-server https://headscale.example.com, I keep 3000 off ss on 0.0.0.0, and I leave browser SSH and OIDC until the restore is boring. Headscale is still the control plane. Headplane is the tab I open instead of docker exec.
Did you hit the same wall?
I got stuck on cookie_secure: true against http://IP:3000/admin (and almost left official compose on Headscale 0.26.0, which 404s /version under Headplane 0.7.x). Did you hit the same thing, or a different one — cookie_secret not 32 characters, headscale.url still on :5000, listen_addr stuck on 127.0.0.1 inside Docker, / instead of /admin, DNS edits missing because the config dest paths differed? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan
References
- Headplane website
- Headplane Docker installation
- Headplane native mode and Nginx example
- Headplane configuration
- config.example.yaml
- Headplane v0.7.1 release notes
- Headplane v0.7.0 — Headscale 0.27+ / 0.29
- Headscale container install
- Headscale v0.29.3
- Self-Host Weekly, 9 Oct 2026
- Docker Engine install documentation