A UI for Headscale (headplane setup)

September 6, 2025

updated Oct 12, 2025

I setup headscale a few days ago and have been thinking about a UI for it since. It feels like early days for this software despite headscale being 4 years old at time of writing. There are a number of UI packages out there for Headscale, all taking advantage of the API. Various levels of functionality come with each.

headscale-ui (gurucomputing) and headscale-admin are both easy to configure but not as feature complete as headplane. Headplane by tale is impressive but again it’s all still feeling very early in the process. Headplane, unlike the other 2 mentioned here (there are more but for my purposes these were the ones I experimented with) creates a node on the tailnet and is as feature complete as it can be.

To get headplane working I had to look through a whole lot of examples and in the end resorted to the Discord for headscale (with a channel for web-interfaces) to figure it out. The folks there are generous with their time and got me to where I was trying to go, @Nathanaƫl was particularly helpful and patient.

In the end I added the following to the service to my docker-compose for headscale

  headplane:
    container_name: headplane
    image: ghcr.io/tale/headplane:latest
    restart: unless-stopped
    ports:
      - '3000:3000'
    networks:
      - pub
    depends_on:
      - headscale
    volumes:
      - ./headplane/config/config.yaml:/etc/headplane/config.yaml
      - ./config/config.yaml:/etc/headscale/config.yaml
      - ./headplane/data:/var/lib/headplane
      - /var/run/docker.sock:/var/run/docker.sock:ro

A few things to note here. It’s running on the SAME domain as headscale, without this you’re stuck adding CORs (cross origin records) labels as traefik middleware which is yet another layer of complexity I didn’t want to get into. (I gave up on the traefik labels for 2 reasons… 1 it’s on the internal network and works fine with the IP as the URL, using headscale gives me access from anywhere so why put it out there? 2 it has direct access to the docker socket so inviting access to that seems unwise). It’s also on the same docker network as headscale and weirdly it seems to require these explicit paths to files for the config files, without the full path it can’t seem to find them. You can see it connects to the docker socket (which feels like a security risk, same as traefik, so I’m still trying to figure out if I need to do something about this). This is required for the “integration” mode. You can see that it needs access to the headscale config in addition to it’s own config.

The headplane config file (which is a separate requirement) was quite gnarly for me to setup. I went through many iterations and in the end had to ask for help. It did finally work out as the following.

# Configuration for the Headplane server and web application
server:
  host: "0.0.0.0"
  port: 3000

  # The secret used to encode and decode web sessions
  # Ensure that this is exactly 32 characters long
  cookie_secret: "<valid sekrit here>"

  # Should the cookies only work over HTTPS?
  # Set to false if running via HTTP without a proxy
  # (I recommend this is true in production)
  cookie_secure: false

# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:
  # The URL to your Headscale instance
  # (All API requests are routed through this URL)
  # (THIS IS NOT the gRPC endpoint, but the HTTP endpoint)
  #
  # IMPORTANT: If you are using TLS this MUST be set to `https://`
  url: "https://hs.mydomain.com"

  # If you use the TLS configuration in Headscale, and you are not using
  # Let's Encrypt for your certificate, pass in the path to the certificate.
  # (This has no effect `url` does not start with `https://`)
  # tls_cert_path: "/var/lib/headplane/tls.crt"

  # Optional, public URL if they differ
  # This affects certain parts of the web UI
  # public_url: "https://headscale.example.com"

  # Path to the Headscale configuration file
  # This is optional, but HIGHLY recommended for the best experience
  # If this is read only, Headplane will show your configuration settings
  # in the Web UI, but they cannot be changed.
  config_path: "/etc/headscale/config.yaml"

  # Headplane internally validates the Headscale configuration
  # to ensure that it changes the configuration in a safe way.
  # If you want to disable this validation, set this to false.
  config_strict: true

  # If you are using `dns.extra_records_path` in your Headscale
  # configuration, you need to set this to the path for Headplane
  # to be able to read the DNS records.
  #
  # Pass it in if using Docker and ensure that the file is both
  # readable and writable to the Headplane process.
  # When using this, Headplane will no longer need to automatically
  # restart Headscale for DNS record changes.
  # dns_records_path: "/var/lib/headplane/extra_records.json"

# Integration configurations for Headplane to interact with Headscale
integration:
  agent:
    # The Headplane agent allows retrieving information about nodes
    # This allows the UI to display version, OS, and connectivity data
    # You will see the Headplane agent in your Tailnet as a node when
    # it connects.
    enabled: true
    # To connect to your Tailnet, you need to generate a pre-auth key
    # This can be done through the `headscale` CLI (or through a web UI that can do this). 
    # eg docker exec -it headscale headscale pre-authkeys create --user <userID>
    pre_authkey: "<valid 48 character pre-authkey generated in headscale on the commandline>"
    # Optionally change the name of the agent in the Tailnet.
    # host_name: "headplane-agent"

    # Configure different caching settings. By default, the agent will store
    # caches in the path below for a maximum of 1 minute. If you want data
    # to update faster, reduce the TTL, but this will increase the frequency
    # of requests to Headscale.
    # cache_ttl: 60
    # cache_path: /var/lib/headplane/agent_cache.json

    # Do not change this unless you are running a custom deployment.
    # The work_dir represents where the agent will store its data to be able
    # to automatically reauthenticate with your Tailnet. It needs to be
    # writable by the user running the Headplane process.
    # work_dir: "/var/lib/headplane/agent"

  # Only one of these should be enabled at a time or you will get errors
  # This does not include the agent integration (above), which can be enabled
  # at the same time as any of these and is recommended for the best experience.
  docker:
    enabled: true

    # By default we check for the presence of a container label (see the docs)
    # to determine the container to signal when changes are made to DNS settings.
#    container_label: "headscale"

    # HOWEVER, you can fallback to a container name if you desire, but this is
    # not recommended as its brittle and doesn't work with orchestrators that
    # automatically assign container names.
    #
    # If `container_name` is set, it will override any label checks.
    container_name: "headscale"

    # The path to the Docker socket (do not change this if you are unsure)
    # Docker socket paths must start with unix:// or tcp:// and at the moment
    # https connections are not supported.
    socket: "unix:///var/run/docker.sock"

  # Please refer to docs/integration/Kubernetes.md for more information
  # on how to configure the Kubernetes integration. There are requirements in
  # order to allow Headscale to be controlled by Headplane in a cluster.
  kubernetes:
    enabled: false
    # Validates the manifest for the Pod to ensure all of the criteria
    # are set correctly. Turn this off if you are having issues with
    # shareProcessNamespace not being validated correctly.
    validate_manifest: true
    # This should be the name of the Pod running Headscale and Headplane.
    # If this isn't static you should be using the Kubernetes Downward API
    # to set this value (refer to docs/Integrated-Mode.md for more info).
    pod_name: "headscale"

  # Proc is the "Native" integration that only works when Headscale and
  # Headplane are running outside of a container. There is no configuration,
  # but you need to ensure that the Headplane process can terminate the
  # Headscale process.
  #
  # (If they are both running under systemd as sudo, this will work).
  proc:
    enabled: false

# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
#oidc:
#  issuer: "https://accounts.google.com"
#  client_id: "bob@gmail.com"

  # The client secret for the OIDC client
  # Either this or `client_secret_path` must be set for OIDC to work
#  client_secret: "<client_secret>"
  # You can alternatively set `client_secret_path` to read the secret from disk.
  # The path specified can resolve environment variables, making integration
  # with systemd's `LoadCredential` straightforward:
  # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"

#  disable_api_key_login: false
#  token_endpoint_auth_method: "client_secret_post"

  # If you are using OIDC, you need to generate an API key
  # that can be used to authenticate other sessions when signing in.
  #
  # This can be done with `headscale apikeys create --expiration 999d`
#  headscale_api_key: ""

  # Optional, but highly recommended otherwise Headplane
  # will attempt to automatically guess this from the issuer
  #
  # This should point to your publicly accessibly URL
  # for your Headplane instance with /admin/oidc/callback
#  redirect_uri: "http://example.com/admin/oidc/callback"

  # Stores the users and their permissions for Headplane
  # This is a path to a JSON file, default is specified below.
#  user_storage_file: "/var/lib/headplane/users.json"

Note, this is the example file copied and then modified to make it work. The main things that changed were:

  • url, note that the port is NOT included here as I’m using traefik and https so 8080 or whatever port headscale is on doesn’t enter into it.
  • TLS settings remain commented out as I’m only exposing the interface internally on my own homelab network.
  • pre_authkey was filled in and the integration enabled. Further down in docker, this was enabled as well and the container-label was commented out and the container_name for the headscale container was correctly added.
  • Finally every line of OIDC was commented out, everything, If you’re not using it (and you don’t have to) you must get rid of all of it or it interferes. Again this was a level of complexity I was unwilling to take on at this time to get this working.

Also note that while creating the pre-authkey you’ll have created a user as well cause the pre-authkey belongs to a user. All this setup enables the node that is headplane to be created and connect to headscale correctly.

To setup a user do

docker exec -it headscale headscale users create <username>

to setup the pre-authkey you’ll use (you can list the users to figure out the id of the user just created for the next bit)

docker exec -it headscale headscale pre-authkeys create --user <userID>

this has to happen with headscale running.

One more thing is to set headscale and headplane running, if you watch headscale and headplane come up, if all is well, headplane will come up and fail once to launch the agent, try a different thing for the second try and succeed and then headplane will show a node attach which is the headplane node (in addition to any nodes you already have attached).

now do

docker exec -it headscale headscale apikeys create -e 999d

and copy the apikey. This is what will let you into the interface of headplane so you can actually make it work for you. That is if you don’t already have an apikey handy for this purpose. Note the expiry of 999 days means this won’t expire. You can expire keys manually as you need to.

Alternate interfaces

Headscale-ui was relatively simple to setup based on other peoples examples and documentation. Note that headscale-ui wants the PathPrefix(`/web`)” and is found at hs.mydomain.com/web unlike headplane and headscale-admin which both want /admin. Due to this headscale-admin doesn’t play well with headplane (can’t really). Of all of these headplane has the most complete functionally which is why I’m favouring it (now that I’ve got it working).

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.