headplane: a web UI whose container replaces the shell with a stub, and whose next tag moves every release
A feature-complete Web UI for Headscale
At a glance
- What is it?
- A TypeScript front end and a Go back end in one repository for the self-hosted VPN server that ships without an interface, wrapping a server-side agent, a WebAssembly SSH client and a health check. The Compose file in the repository says in capitals that it is not something to deploy, and the pre-release tag is updated whenever a release request is opened.
- Who is it for?
- headplane fits an operator of the self-hosted VPN server who wants the missing interface and is willing to track a fast release cadence. Four things to know.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 6 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 10, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The Compose file says in capitals that it is not a deployment example
The first three lines of the Compose file are a warning in capitals from the maintainer: this is a developer configuration file, it is not an example of something you deploy, and it is only used for developing the project. That is worth taking at face value, because the file is the only deployment-shaped artifact in the repository. The README does not offer installation steps at all and refers to the project's website for them, so a reader who clones the repository has a Compose file that says it is not for them and a readme that says the instructions are elsewhere. What the file does show is the shape of a development stack: a reverse proxy on a versioned tag with its configuration file mounted and its data, configuration and certificate directories mounted from a test folder, and the server itself on a pinned version with its state directory and its entire configuration directory mounted from the same test folder, two ports published and a hard-coded timezone.
The final image replaces the shell with a stub that tells you to use a debug image
The container build has three stages and the last one does something unusual. The final stage is a distroless Node image, so there is no shell in it at all, and the build then copies a small Go binary over both the system shell and the bash path, with a comment explaining that this is a fake shell put there to inform the user they should be using the debug image. The effect is that a container exec, a debugging session or any tooling that expects a shell will not get one; it will get a message. That is a deliberate hardening decision rather than an accident, and it is the kind of detail that only shows up if you read a Dockerfile end to end. It also means the image cannot be inspected in place, which is the point, and which is worth knowing before you try to debug a running deployment with it.
One build script produces four binaries, including a WebAssembly SSH client
The Go stage installs a patching tool, copies the module files and a patches directory, downloads the modules, and then calls a single build script with four named outputs: a WebAssembly SSH client, a native agent, a stub shell and a health check binary. The whole build is one line, cross-compiling with the C compiler disabled:
./build.sh --wasm --agent --fake-shell --healthcheck \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--fake-shell-output /bin/fake-sh \
--healthcheck-output /bin/hp_healthcheckEach of the four is then marked executable, and a directory is created for the agent's state. The cross-compilation arguments and a disabled C compiler are passed in the same command, so the agent is a static binary that runs on a distribution nobody has to match. The WebAssembly binary and a companion JavaScript file are copied forward into the Node stage and end up in the served public directory, which means the browser side of the interface can run the same SSH implementation the server can. Four binaries from one script, one of which is a deliberate stub, is a compact description of what this project actually ships.
The next tag is mutable, so it is not a version
The versioning section is four sentences and the third one is the important one. Releases follow semantic versioning, but only since version zero point six, which dates the policy rather than the project. Pre-release builds are published under a tag called next, and those builds are updated when a new release request is opened and is being tested. So next is not a version, it is a moving pointer that changes every time somebody opens a release request, and anything deployed from it can change underneath a running container with no version change to record. The visible tags follow the same shape: a release, another release, and a pre-release of that second release. For an operator, the practical rule is to pin a versioned tag or an image digest and treat next as a thing to test against, never to run.
The Go half depends on Tailscale at a pinned release
The module file for the Go half declares a language version and three direct requirements: a memory interface library, a cryptography library at a pinned release, and the Tailscale module itself at a pinned release. That third one is the whole architecture in a line. The project does not only talk to the server over an API; it links against the same libraries the server is built from, and the long indirect list is mostly Tailscale sub-packages: human readable JSON, Windows support helpers, certificate storage, peer credentials and WireGuard itself. The practical consequence is a coupling you cannot see from the README: upgrading the interface can pull in a new Tailscale release, and a Tailscale release can pull new transitive dependencies into the image, whether or not the interface changed.
feature-complete appears three times, and the benchmark is the official dashboard
The phrase feature-complete is in the repository description, in the tagline at the top of the readme, and again in the sentence that introduces the project as a web interface for the server that ships without one. The paragraph after it sets the goal precisely: to replicate the functionality offered by the official hosted product and its dashboard, while describing itself as one of the most complete interfaces available. That is a defensible framing, because parity with a closed dashboard is a concrete target and the feature list backs part of it, covering machine management with expiry, routing, names and owners, access control lists and tagging, an external identity provider, DNS settings and the server's own configuration. What the framing does not give a reader is a scope boundary, so the only way to judge the claim is the documentation site or the interface itself.
The interface can rewrite the server's own configuration
Two of the five feature bullets are about editing the server rather than the network. One says the project can edit DNS settings and automatically provision the server, and another says it is configurable for the server's settings. Provisioning a server from a web interface means the interface writes configuration that the server reads on its next start, which is a materially different capability from managing machines and access control lists. It also explains the repository's shape: an example configuration file at the root, a mount of an entire configuration directory in the development stack, a native agent binary installed into the image and a health check binary beside it. A reader deciding whether to expose this interface to a team should treat that capability as the thing to reason about, rather than the node and ACL screens that are easier to picture.
A preinstall step installs its own package manager police with a one-off run
The front end manifest opens with a preinstall script that runs a package called only-allow through the one-off execution flag, which is the package that fails the install unless the expected package manager is the one running. It is not declared as a dependency in either the runtime or development list, so on a machine without it the install fetches it, and the check that is supposed to make installs reproducible depends on a download. The same pattern appears in the release script, which runs a TypeScript file directly under the interpreter with a flag that suppresses the experimental warning, so it depends on the runtime's type stripping rather than on a build step. Both choices are small and both are consistent with a project that has replaced most of its conventional tooling: a Rust based linter and formatter instead of the JavaScript ones, a task runner managed by a version manager file, a hooks file for commits and dependency patches in a directory.
Editorial conclusion
headplane fits an operator of the self-hosted VPN server who wants the missing interface and is willing to track a fast release cadence. Four things to know. Installation is not in the repository; the README sends you to the website and the Compose file there in is explicitly a developer configuration. The pre-release tag is mutable by design, so pin a versioned tag or a digest rather than it. The image ships four binaries, one of which replaces the shell so nobody can quietly exec into a container and change it. And the interface can write the server's own configuration, which is a capability to think about before exposing it to more people.
Frequently asked questions
What is Headplane and what is it used for?
A web interface for Headscale, the self-hosted implementation of a WireGuard based VPN service that ships without one. It manages machines including expiry, network routing, name and owner, configures access control lists and tagging for enforcement, supports OpenID Connect as a login provider, and can edit DNS settings and the server's own configuration.
How do I install Headplane?
The README does not say. It refers you to the project's website for installation instructions, and the Compose file in the repository says in capitals that it is a developer configuration, not an example of something to deploy.
What does the Headplane container image contain?
Four binaries built by one script: a WebAssembly SSH client served to the browser, a native agent, a health check, and a stub shell. The final stage is a distroless Node image, and the stub replaces the system shell and bash with a message telling you to use a debug image.
What is the next tag in Headplane releases?
A moving pre-release pointer. The README says pre-release builds are published under next and are updated when a new release pull request is opened, so anything deployed from that tag can change with no version change. Stable releases follow semantic versioning only since version 0.6.0.
Why does Headplane's Go code depend on Tailscale?
Its module file requires the Tailscale module at a pinned release, and the indirect list is mostly Tailscale sub-packages: human readable JSON, Windows helpers, certificate storage, peer credentials and WireGuard itself. So an interface upgrade can pull a new Tailscale release into the image even when the interface did not change.
Does the Headplane interface change the server's configuration?
Yes. The feature list includes editing DNS settings, automatically provisioning Headscale, and configuring Headscale's settings, which means the interface writes configuration the server reads on its next start. That is a different class of capability from managing machines and access control lists.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/tale-headplane)