Containers

Run Headscale with a Web UI in Docker (Headplane + Caddy)

Headscale runs happily in one container, but a coordination server nobody can reach over HTTPS and nobody can manage without a terminal is only half a deployment. The Compose stack below fixes both with three containers: Headscale itself, Headplane as the web UI and Caddy in front for TLS. Only Caddy publishes ports, and Headplane can edit the Headscale config and restart the container through the Docker socket, so DNS records and policy changes you make in the browser take effect without you touching the server.

Original content from computingforgeeks.com - post 146067

Earlier versions of this guide used headscale-ui, whose last release came out in March 2026. Headplane ships more often and manages far more of Headscale, including DNS, policy and config. Every command below was run on an Ubuntu 26.04.1 LTS server with Docker 29.9.2, Headscale 0.29.5, Headplane 0.8.1 and Caddy 2.11.7 in October 2026, with a second Ubuntu 26.04 machine joining the tailnet.

Plan the deployment

Tailscale clients only talk to a coordination server over HTTPS, so you need a domain name before anything else. Caddy proves you own that name through an ACME challenge on port 80 or 443, which means the record has to resolve before the first start. Have these ready:

  • a Linux server or VM with 2 vCPU and 2 GB RAM or more
  • an A record such as headscale.example.com pointing at it
  • TCP 80 and 443 open to the internet
  • UDP 443 open if you want HTTP/3 to the web UI

Nothing else needs to be reachable from outside, because Headscale and Headplane only listen on the private Compose network. Keep SSH limited to your own address while you are at it, because this host ends up holding the keys to every node on the tailnet. Ports that Docker publishes skip ufw rules on the host, so filter 80 and 443 at your cloud or network firewall if you need to restrict them. This is what the stack looks like once it’s running:

Architecture diagram: a Tailscale client reaches Caddy on HTTPS 443, Caddy proxies /admin to Headplane on 3000 and everything else to Headscale on 8080, Headplane calls the Headscale API

Clients reach Caddy on 443, Caddy sends /admin to Headplane and everything else to Headscale, and Headplane calls the Headscale API with a key you create later. Once two nodes are registered they send WireGuard traffic to each other directly, so the server only carries control traffic and a small VM is enough. The lab ran on 2 vCPU and 4 GB, which was more than the stack needed.

If you would rather run Headscale as a systemd service behind nginx, follow our guide to install Headscale on Ubuntu instead. That guide also covers access policies, subnet routes and the embedded DERP server in more depth.

Install Docker Engine

Use the Docker repository rather than the docker.io package from Ubuntu, because current Engine releases and the Compose plugin come from there. The two sets of packages conflict, so remove the Ubuntu ones first if any are installed. This command from the Docker docs removes nothing when none of them are present:

sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null | cut -f1)

Then add the key and the source file:

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
printf '%s\n' 'Types: deb' 'URIs: https://download.docker.com/linux/ubuntu' "Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" 'Components: stable' "Architectures: $(dpkg --print-architecture)" 'Signed-By: /etc/apt/keyrings/docker.asc' | sudo tee /etc/apt/sources.list.d/docker.sources

Install the Engine and the Compose plugin, then add your user to the docker group so you can run Compose without sudo. Membership in that group is root-equivalent on the host, so only add accounts you would trust with sudo anyway. On our 26.04 server the install pulled 7 packages, a 102 MB download that took 388 MB on disk:

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

Log out and back in so the group change applies, because a running shell keeps its old group list. Then check the versions:

docker --version; docker compose version

The Docker repo already carries packages for Ubuntu 26.04 (resolute), and on our server it installed these versions.

Docker version 29.9.2, build a82e528
Docker Compose version v5.6.1

On a fresh cloud image the install can stop with “Could not get lock /var/lib/dpkg/lock-frontend”, because unattended-upgrades runs right after boot and holds the lock for a few minutes. It happened on our VM, so if you see it, wait and run the install again rather than deleting lock files. Our Docker CE on Ubuntu 26.04 guide covers the Engine install in more detail.

Create the Headscale configuration

Everything lives in one directory, which also makes the backup later a single tar command. Create it with a folder for the data of each container.

mkdir -p ~/headscale/{config,lib,headplane,headplane-data,caddy-data,caddy-config}
cd ~/headscale

Start from the example config that matches the image tag you run. Options get renamed between Headscale releases, and a config copied from an older guide can stop the server from starting, so always take it from the same tag as the image. The variable below pins the version for that reason, so change it together with the tag in compose.yaml.

export HEADSCALE_VERSION=0.29.5  # pin: must match the image tag in compose.yaml
curl -fsSL -o config/config.yaml https://raw.githubusercontent.com/juanfont/headscale/v${HEADSCALE_VERSION}/config-example.yaml

Six settings need changing for a container behind a reverse proxy. The public URL goes in server_url, and Headscale has to listen on all interfaces inside the container so Caddy and Headplane can reach it. The subnet of the proxy goes in trusted_proxies so client IPs in the logs are real, MagicDNS gets its own domain, and the policy moves into the database so Headplane can edit it.

sed -i -e 's|^server_url:.*|server_url: https://headscale.example.com|' -e 's|^listen_addr:.*|listen_addr: 0.0.0.0:8080|' -e 's|^metrics_listen_addr:.*|metrics_listen_addr: 0.0.0.0:9090|' -e 's|^trusted_proxies: \[\]|trusted_proxies:\n  - 172.30.0.0/24|' -e 's|^  base_domain:.*|  base_domain: tailnet.internal|' -e 's|^  mode: file|  mode: database|' config/config.yaml

Replace headscale.example.com with your own domain before you run it. A sed that finds no match changes nothing and prints nothing, so confirm all six edits landed.

grep -nE '^(server_url|listen_addr|metrics_listen_addr):|^trusted_proxies|^  - 172.30|^  base_domain|^  mode:' config/config.yaml

You should see one line for each change. If one is missing, the example file changed its layout and you need to edit that key by hand:

13:server_url: https://headscale.example.com
19:listen_addr: 0.0.0.0:8080
24:metrics_listen_addr: 0.0.0.0:9090
47:trusted_proxies:
48:  - 172.30.0.0/24
298:  mode: database
331:  base_domain: tailnet.internal

The base_domain must not be the domain of your server or a parent of it. Headscale refuses to start with “server_url cannot be part of base_domain” if you leave example.com from the example in place with a server URL under it. Listening on 0.0.0.0 is safe here, because the Compose file never publishes 8080 or 9090 to the host, so only containers on the same network can reach those ports.

Write the Compose file

The Compose file defines three services on a private network with a fixed subnet, which is what makes the trusted_proxies entry above match the address Caddy gets. Each service has one job:

ServiceImagePortJob
headscaleheadscale/headscale:0.29.58080, network onlyCoordination server, SQLite in ./lib
headplanetale/headplane:0.8.13000, network onlyWeb UI under /admin
caddycaddy:2.11.780 and 443 on the hostTLS and reverse proxy

Create the file in the stack directory. Compose picks up compose.yaml automatically, so every docker compose command below assumes you are in ~/headscale:

vim ~/headscale/compose.yaml

Paste the full stack. The image tags are pinned so a docker compose pull never upgrades Headscale behind your back, and each tag carries a comment with its release page:

services:
  headscale:
    image: docker.io/headscale/headscale:0.29.5 # https://github.com/juanfont/headscale/releases
    container_name: headscale
    restart: unless-stopped
    command: serve
    read_only: true
    tmpfs:
      - /var/run/headscale
    volumes:
      - ./config:/etc/headscale
      - ./lib:/var/lib/headscale
    labels:
      - me.tale.headplane.target=headscale
    healthcheck:
      test: ["CMD", "headscale", "health"]
    networks:
      - headscale

  headplane:
    image: ghcr.io/tale/headplane:0.8.1 # https://github.com/tale/headplane/releases
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml:ro
      - ./headplane-data:/var/lib/headplane
      - ./config:/etc/headscale
      - /var/run/docker.sock:/var/run/docker.sock:ro
    depends_on:
      - headscale
    networks:
      - headscale

  caddy:
    image: docker.io/library/caddy:2.11.7 # https://github.com/caddyserver/caddy/releases
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy-data:/data
      - ./caddy-config:/config
    networks:
      - headscale

networks:
  headscale:
    ipam:
      config:
        - subnet: 172.30.0.0/24

A few lines in there do more than they look. The Headscale image is distroless and runs with a read-only root filesystem, so the socket directory has to be a tmpfs. The me.tale.headplane.target label is how Headplane finds the container it should restart after a config change. The Headscale docs mount ./config read-only, but here it stays writable because Headplane edits that file.

The Docker socket is the trade-off to understand before you go live. Headplane itself only uses it to find the Headscale container and restart it, so a person logged in to Headplane controls the tailnet, not the Docker host. The real risk is a compromised Headplane container, because the :ro flag does not limit Docker API calls, so code running there could start a privileged container and take over the host. The Headplane Docker install docs suggest a socket proxy such as docker-socket-proxy, which you can limit to the container list and restart calls, if you want to close that gap.

Configure Caddy for HTTPS

Caddy fetches and renews a Let’s Encrypt certificate on its own once the domain resolves to the server, so the whole proxy config fits in four lines. Use your own domain on the first line, the same one you put in server_url. Renewal happens in the background while the domain keeps pointing here, so there’s no certbot timer to manage. Create the Caddyfile:

vim ~/headscale/Caddyfile

Send /admin to Headplane and everything else to Headscale. Caddy sorts the two lines itself, so the one with a path matcher runs first and the catch-all only gets what is left:

headscale.example.com {
	reverse_proxy /admin* headplane:3000
	reverse_proxy headscale:8080
}

Caddy proxies WebSocket upgrades without extra settings, which the Tailscale control protocol needs, so there’s nothing to add for the clients. Our lab server had no public DNS, so we gave Caddy a certificate from a private CA through a compose.override.yaml and trusted that CA on the client. On a server with a real domain you skip all of that, because Caddy gets the certificate the first time it starts. For more on Caddy itself, see our guide to installing Caddy on Ubuntu 26.04.

Configure Headplane

Headplane reads one YAML file. It needs the public URL, the address of Headscale on the Compose network, the path where it finds the Headscale config, and the Docker integration that lets it restart Headscale. Create the file.

vim ~/headscale/headplane/config.yaml

Add this configuration, again with your own domain. The headscale.url uses the container name and the internal port, so this traffic never leaves the Compose network:

server:
  host: "0.0.0.0"
  port: 3000
  base_url: "https://headscale.example.com"
  cookie_secret: "REPLACE_WITH_32_CHARACTERS"
  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"

integration:
  docker:
    enabled: true
    container_label: "me.tale.headplane.target=headscale"
    socket: "unix:///var/run/docker.sock"

The cookie secret signs your browser sessions and must be exactly 32 characters. openssl rand -hex 16 prints 32 hex characters, which fits, while -hex 32 prints 64 and Headplane refuses to start. Put a fresh one in place.

sed -i "s|REPLACE_WITH_32_CHARACTERS|$(openssl rand -hex 16)|" ~/headscale/headplane/config.yaml

Leave base_url without the /admin suffix. Headplane adds that prefix itself and its docs say base_url must not include it, while Caddy only forwards paths under /admin to the container.

Start the stack

Pull the images and start everything in the background. The images take about 600 MB on disk once pulled, so give the first start a minute on a slow link:

cd ~/headscale
docker compose up -d
docker compose ps --format 'table {{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'

Within a minute all three should be up. Headplane reports healthy almost at once, and Headscale shows health: starting until its first check passes a few seconds later. Caddy is the only one with published ports.

NAME        IMAGE                                  STATUS                             PORTS
caddy       docker.io/library/caddy:2.11.7         Up 22 seconds                      0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 0.0.0.0:443->443/udp, [::]:443->443/udp, 2019/tcp
headplane   ghcr.io/tale/headplane:0.8.1           Up 22 seconds (healthy)
headscale   docker.io/headscale/headscale:0.29.5   Up 22 seconds (health: starting)   

The three images take about 605 MB on disk, and 399 MB of that is Headplane. Pull the version and listener lines out of the Headscale log:

docker compose logs headscale | grep -E 'version=|listening'

The warning about TLS is expected, because Caddy terminates HTTPS and Headscale itself serves plain HTTP inside the network:

headscale  | 2026-10-22T17:50:54Z INF starting headscale commit=0f7d1e536bed9f01dd7161f7637af19bb0908335 version=v0.29.5
headscale  | 2026-10-22T17:50:54Z INF Clients with a lower minimum version will be rejected minimum_version=v1.80
headscale  | 2026-10-22T17:50:55Z WRN listening without TLS but ServerURL does not start with http://
headscale  | 2026-10-22T17:50:55Z INF listening and serving HTTP on: 0.0.0.0:8080
headscale  | 2026-10-22T17:50:55Z INF listening and serving debug and metrics on: 0.0.0.0:9090

The Headplane log should end with “Using container: /headscale” and “Listening on http://0.0.0.0:3000”, which confirms the Docker integration found Headscale through the label. A warning that Headscale was unreachable at boot is harmless, since the two containers start at the same moment and Headplane retries. Finally, test the public endpoint through Caddy:

curl -s https://headscale.example.com/health

A healthy server answers with a single JSON line. Anything else, or a certificate error, points at DNS or Caddy rather than Headscale:

{"status":"pass"}

From node01 on the same LAN, ports 8080 and 3000 on the server refused connections while 443 returned 200. That’s the point of the layout. Nothing reaches Headscale or Headplane except through Caddy, so TLS and any access rules you add to the Caddyfile apply to every request.

Create a user and keys

The Headscale CLI is inside the container, so every admin command runs through docker exec. Create a user, which owns the nodes you register:

docker exec headscale headscale users create josphat
docker exec headscale headscale users list

Note the ID in the first column, because pre-auth keys reference the user by ID. You will pass it to --user in a moment:

ID | Name | Username | Email | Created
1  |      | josphat  |       | 2026-10-22 17:33:08

Headplane logs in with a Headscale API key. Create one that expires in 90 days and keep the printed key somewhere safe, since Headscale only stores a hash of it and can’t show it to you again. When it expires the Headplane login stops working, so put a reminder in your calendar or create the key with a longer expiry.

docker exec headscale headscale apikeys create --expiration 90d

Devices join with a pre-auth key, which saves you approving each one by hand. A one-hour key for user 1 is enough to register a machine.

docker exec headscale headscale preauthkeys create --user 1 --expiration 1h

The API key starts with hskey-api- and the pre-auth key with hskey-auth-. Add --reusable when you need one key for several machines, such as a batch of servers built by Ansible. Keep reusable keys short-lived, because anyone holding one can add machines to your tailnet until it expires.

Join devices to the tailnet

Clients run the normal Tailscale client and point it at your server instead of the Tailscale service. On Ubuntu, the Tailscale install script adds the repository and installs the package.

curl -fsSL https://tailscale.com/install.sh -o /tmp/ts-install.sh
sudo sh /tmp/ts-install.sh

Join with --login-server and the pre-auth key. The command returns silently when it works. If it hangs or errors, run curl https://headscale.example.com/health from that machine first, since a DNS or certificate problem shows up there too.

sudo tailscale up --login-server https://headscale.example.com --auth-key hskey-auth-xxxxxxxxxxxx

We joined two Ubuntu 26.04 machines running Tailscale 1.104.2, the client node01 and the Docker host hs01 itself. Check the tailnet from either one.

tailscale status
tailscale ping -c 3 hs01

Both nodes got addresses from 100.64.0.0/10, and the ping went straight across the LAN rather than through a relay:

100.64.0.1  node01  josphat  linux  -
100.64.0.2  hs01    josphat  linux  -
pong from hs01 (100.64.0.2) via 10.0.1.50:41641 in 2ms

MagicDNS works with no extra setup, so ping hs01.tailnet.internal resolves to 100.64.0.2 on every node. For Tailscale on other systems, see our guides to install Tailscale on Ubuntu and to join pfSense to a Headscale mesh.

Manage the tailnet from Headplane

Open https://headscale.example.com/admin in a browser. Headplane asks for the API key you created earlier, and the session lasts a day before you sign in again.

Headplane login page asking for a Headscale API key

After you sign in, the Machines page lists every node with its owner, tailnet address and connection state. From the menu on each row you can rename a node, turn on key expiry, change its routes and ACL tags, or remove it. Every change goes through the Headscale API, so the CLI and the UI always show the same state:

Headplane Machines page listing two connected Headscale nodes

The DNS page is the best test of the Docker integration, because a DNS record lives in the Headscale config file rather than the database. We added an A record for grafana.tailnet.internal pointing at 100.64.0.2.

Headplane Add DNS record dialog with grafana.tailnet.internal and 100.64.0.2

After Confirm, the record appears in the list. Behind the scenes Headplane wrote it into config.yaml, kept the file’s comments, and logged “Restarting Headscale via Docker” before the container came back about two seconds later. Connected clients did not drop during that restart, because their tunnels don’t depend on the control server:

Headplane DNS Records list showing the saved A record

The record reached the clients without anything being touched on them. Headscale pushes DNS changes to clients with the next network map, and the record was there when we checked node01 a few seconds later:

getent hosts grafana.tailnet.internal

On node01 it resolved to the address we entered. That one test proves the whole chain works: the UI, the config write, the container restart and the push to clients.

100.64.0.2      grafana.tailnet.internal

Access Control is where you write the tailnet policy. With no policy saved, Headscale allows every node to reach every other node, which is fine for a home lab but isn’t for anything shared with other people. The editor works because we set policy.mode: database, so Headscale stores the policy where Headplane can change it.

Headplane Access Control page with rules, SSH rules and hosts sections

The “Traffic is denied unless a rule allows it” line describes a saved policy. Until you save one, Headscale has no policy loaded and allows everything, but the moment you save a policy with an empty grants or acls list, every node loses access to every other node. Write at least one allow rule before you press Save. The rule syntax is the Tailscale ACL and grants format, and the Ubuntu Headscale guide linked earlier has a tested policy you can paste into the Edit file tab.

Fix common Headscale and Headplane errors

These came up while we built the stack, with what each one means for you.

server.cookie_secret must be exactly length 32

Headplane’s container restarts in a loop and its log shows “Unable to load configuration” with this line, plus the length it found. The secret has to be exactly 32 characters, so generate it with openssl rand -hex 16 and restart Headplane with docker compose restart headplane.

server_url cannot be part of base_domain

Headscale exits on start because MagicDNS’s base_domain contains the server’s own domain, for example a server_url of https://headscale.example.com with base_domain: example.com. Use a separate name such as tailnet.internal.

WRN listening without TLS but ServerURL does not start with http://

This warning is expected behind Caddy, which handles HTTPS while Headscale serves plain HTTP on the Compose network. It only matters if you publish port 8080 directly, which this layout avoids. Leave server_url on https, because clients would otherwise try to register over plain HTTP.

Could not get lock /var/lib/dpkg/lock-frontend

Apt is already running, usually unattended-upgrades on a freshly booted VM. Wait for it to finish, or pass -o DPkg::Lock::Timeout=600 to apt so it waits on its own.

Back up and upgrade

Everything that matters is in the ~/headscale directory. Our archive held these files:

  • lib/db.sqlite, the nodes, users and keys
  • lib/noise_private.key, the server identity
  • config/config.yaml and headplane/config.yaml
  • headplane-data/hp_persist.db, the Headplane state

Stop Headscale briefly so the SQLite file is consistent, archive the directory, and start it again. Connected clients keep working while Headscale is down, because peers talk to each other directly. We stopped the container and node01 still pinged hs01.tailnet.internal with no packet loss:

cd ~/headscale
docker compose stop headscale
sudo tar czf ~/headscale-backup-$(date +%F).tar.gz config lib headplane headplane-data compose.yaml Caddyfile
docker compose start headscale

Our backup came to 15 KB with two nodes registered, and both nodes showed online again within seconds of the restart. Copy the archive off the server, because losing lib/noise_private.key means every client has to register again. Treat the archive as a secret, because it also holds the Headplane cookie secret. A nightly job in root’s crontab running the same commands after a cd into the directory is enough for a small tailnet, since the whole archive is a few kilobytes.

To restore, stop the stack, clear any SQLite journal files a crashed Headscale left behind, unpack the newest archive over the directory and start it again. We deleted lib/db.sqlite and the noise key in the lab, restored, and both nodes came back online with the same addresses without registering again:

BACKUP=$(ls -t ~/headscale-backup-*.tar.gz | head -1)
cd ~/headscale
docker compose down
sudo rm -f lib/db.sqlite-wal lib/db.sqlite-shm
sudo tar xzf "$BACKUP" -C ~/headscale
docker compose up -d

A restore only knows about nodes that existed when the archive was made. In a second test, node01 had logged out and joined again after the backup, so after the restore it showed offline and its client reported “Logged out” until we ran tailscale up with a fresh pre-auth key. Take a backup after you add machines, not just on a schedule.

Upgrades need more care than the restore, because Headscale database migrations only run forward. Its upgrade docs require going from one minor version to the next without skipping any, using the latest patch release of each, so 0.29.5 goes to the newest 0.30.x before 0.31. Work through it in this order:

  1. Read the release notes for Headscale and for Headplane
  2. Take a backup as shown above
  3. Diff your config.yaml against the new example file
  4. Change the Headscale tag in compose.yaml by one minor version
  5. Run docker compose pull and docker compose up -d
  6. Check docker compose ps headscale and the Headscale log

Headplane checks for a minimum Headscale version, so bump its tag too when its release notes say so. If Headscale keeps restarting after the change, docker exec can’t reach it, so read the error in docker compose logs headscale and run the restore commands above. The archive includes compose.yaml, so the restore puts the old tag back as well. If you only need a handful of fixed links and no coordination server at all, a plain WireGuard setup on Ubuntu 26.04 is the lighter option.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Best OpenShift Books for Admins and EX280 Candidates Books Best OpenShift Books for Admins and EX280 Candidates 6 Ways to Keep Hackers Out of Your Website Security 6 Ways to Keep Hackers Out of Your Website

1 thought on “Run Headscale with a Web UI in Docker (Headplane + Caddy)”

  1. Hi,
    thank you for the guide.
    Just I think you shoud modify the compose file and remove the string headscale in the command line.
    BTW everything seems working well in the CLI part, but I cannot connect the UI to the server 🙁

    Reply

Leave a Comment

Press ESC to close