# headscale: one tailnet, no container support, five tools before make

> juanfont/headscale is a Go implementation of the Tailscale control server that deliberately serves a single tailnet, tells you not to run it in a container or behind a reverse proxy, and gates every make target behind a check for five tools. Its integration suite runs against upstream Tailscale HEAD, which is how a reimplementation stays honest.

**juanfont/headscale** — An open source, self-hosted implementation of the Tailscale control server

- Repository: https://github.com/juanfont/headscale
- Stars: 44,275 · Forks: 2,597
- Language: Go
- License: BSD-3-Clause
- Published: 2026-08-17 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/juanfont-headscale

## The design goal is one tailnet, and the README sets that limit itself

Most of what you need to know about headscale's ceiling is in one sentence of the design goal. It aims to be a self-hosted open source alternative to the Tailscale control server, and it implements a narrow scope: a single Tailscale network, called a tailnet, suitable for personal use or a small open source organisation. The word single is emphasised in the original.

What the control server actually does is worth restating, because it explains what you get by hosting it. It works as an exchange point for Wireguard public keys for the nodes in the network, it assigns the IP addresses of the clients, it creates the boundaries between each user, it enables sharing machines between users, and it exposes the advertised routes of your nodes. All of that happens inside the one tailnet.

The consequence is concrete for anyone evaluating it as shared infrastructure. There is no multi-tenant control plane here, no organisation isolation across separate customers, and no story for a service you sell. A team of thirty people on one tailnet is the shape it was designed for; a SaaS offering a tailnet per customer is a different product, and the README does not pretend otherwise.

## The README rules out containers and reverse proxies, and the releases ship images anyway

Under Running headscale there is a bolded instruction that reads: please note that we do not support nor encourage the use of reverse proxies and container to run Headscale. Then the next section says development builds from the main branch are available as container images and binaries.

Those two statements sit close together and mean different things. Container images are produced for development and for the project's own test infrastructure, not as a supported deployment target. The gap between them is where a self-hoster loses an afternoon: the image exists, the pull works, and the documentation still says this is not how the project wants to be run, so an issue you file about it lands outside the supported surface.

The reverse proxy part has a direct technical consequence. TLS termination and path handling are the two things a reverse proxy is normally there for, and ruling one out means the service is expected to deal with them. For a self-hoster this is not fatal, it is just another thing to configure before the first node registers, and it removes the easy path of putting the control server behind an existing HTTPS setup.

## Six Dockerfiles, and three of them exist to catch upstream changes

The root directory carries six Dockerfiles, and the names say what the project is doing. Dockerfile.derper builds the DERP relay, which is the second half of the deployment: a control server assigns addresses and keys, and the relays carry traffic for peers that cannot reach each other directly. derp-example.yaml at the root is the configuration for that half.

The other five are about verification. Dockerfile.tailscale-HEAD, Dockerfile.tailscale-rs and Dockerfile.wasmclient build client implementations to test against, and Dockerfile.integration plus Dockerfile.integration-ci are the test harness itself. There is no Dockerfile for shipping headscale to users, which is consistent with the note about containers.

Building against upstream HEAD is the interesting choice. A talk at Fosdem 2023 by Juan Font Alonso and Kristoffer Dalby is titled Headscale: How we are using integration testing to reimplement Tailscale, and the Dockerfile names are that strategy in the repository layout. The benefit is that a change in the real client that headscale has not caught up with turns into a failing build here rather than a user report. The cost is that headscale's own release cadence is partly hostage to how fast the upstream protocol moves, and a Fosdem 2026 talk by Kristoffer Dalby is titled Headscale and Tailscale: The complementary open source clone, which is the same relationship described from the other side.

## main can carry unreleased changes, which is why the config example is versioned with the tag

The first note in the README is a warning about exactly this. It says to always select the same GitHub tag as the released version you use, to make sure you have the correct example configuration, because the main branch might contain unreleased changes.

That instruction only makes sense if config-example.yaml and derp-example.yaml change together with the code, and they do, because they live in the same repository on the same branch. So the failure mode is specific: you read main, copy an example config that has a key the code has just changed, and install v0.29.4. The binary starts, meets a configuration it does not recognise, and you are debugging a config mismatch rather than a bug.

The project splits its documentation the same way, with a stable version at headscale.net/stable/ and a development version at headscale.net/development/. Two URLs, one per branch, and the choice between them is the same choice as the tag. There is a CHANGELOG.md at the root for reading what moved between versions, and the release sequence is v0.29.2 on 2026-07-01, v0.29.3 on 2026-07-29 and v0.29.4 on 2026-09-23, so the tags are close enough together that this bites regularly.

## Every make target checks for five tools, including prettier and mdformat

The Makefile opens with a tool check that is unusual in how strict it is. A check_tool macro runs command -v and, on failure, prints a warning telling you to run nix develop to ensure all dependencies are available, then exits 1. The check-deps target applies it five times:

```bash
check-deps:
	$(call check_tool,go)
	$(call check_tool,golangci-lint)
	$(call check_tool,gofumpt)
	$(call check_tool,mdformat)
	$(call check_tool,prettier)
```

The catch is which targets depend on check-deps. build and test both do, as does fmt-go, which means building the Go binary requires prettier and mdformat to be installed, and those are documentation formatters with nothing to do with compilation. The default target is all, and all is lint test build, so a bare make runs all three in that order.

For a contributor this is a feature: the README recommends Nix, flake.nix and flake.lock are at the root along with flakehashes.json and an .envrc, and nix develop gives you the same environment the maintainers have, including Buf for Protobuf generation. For anyone who wants to compile one binary on a machine that has Go and nothing else, it is a wall, and the way through it is to install the five tools or use nix develop.

## The version string comes from git describe, and PIE is on everywhere except four systems

Two build details tell you how the binary is produced. The version is computed as VERSION ?= $(shell git describe --always --tags --dirty) and injected at link time:

```bash
go build $(PIE_FLAGS) -ldflags "-X main.version=$(VERSION)" -o headscale ./cmd/headscale
```

The --dirty flag is the useful part. Build from a working tree with uncommitted changes and the version your binary reports ends in -dirty, which is how you tell at a glance whether a running headscale came from a clean checkout or from a modified tree. GOOS is derived from uname lowercased, and PIE_FLAGS is set to -buildmode=pie except when the system is openbsd, netbsd, solaris or plan9, which is the four-system exception list in the file.

The test target is the other half of the build story, and it is heavier than the build:

```bash
CGO_ENABLED=1 go test -race ./...
```

CGO is forced on for the race detector even though the database driver in the module list, glebarez/sqlite, is a pure Go implementation. Running the suite means compiling with cgo and a C toolchain, and the module requires dockertest and docker alongside a mock OIDC provider, so the test target wants a Docker daemon as well. The README says to run make test and make build, and the Makefile warns you if required tools are missing; make help lists the rest of the targets.

## Generated Protobuf code is committed, so a proto change lands as two commits

Parts of the project need Go code generated from Protobuf when something in proto/ changes, and the regeneration is a make target:

```shell
make generate
```

The note that follows is a review instruction rather than a technical one: check in changes from gen/ in a separate commit to make it easier to review. The gen/ directory is at the root, the Makefile excludes it from the Go source collection alongside vendor/, and the formatting targets skip it too, so generated code is treated as a distinct category rather than as source to reformat.

The toolchain around that is Buf for Protobuf generation, linted with buf and formatted with clang-format, while Go is linted with golangci-lint and formatted with golines at width 88 and gofumpt, docs are formatted with mdformat, and everything else, Markdown and YAML included, goes through prettier. The configurations live in .golangci.yaml, .mdformat.toml, .prettierignore and a .pre-commit-config.yaml, and the README asks you to run make lint and make fmt before committing.

A contributor therefore needs Go, Buf, Protobuf tools, golangci-lint, gofumpt, mdformat and prettier, which is the long list the nix develop shell exists to collapse into one command.

## A maintainer works at Tailscale, and other maintainers review that work

The disclaimer is short and specific. This project is not associated with Tailscale Inc. However, one of the active maintainers is employed by Tailscale and is allowed to spend work hours contributing to the project, and contributions from this maintainer are reviewed by other maintainers.

That arrangement is unusual enough to be worth reading twice in a project that reimplements a company's protocol. It means a paid employee of the upstream vendor commits to this repository, and the guard against that becoming a backdoor is a review by people who are not paid by Tailscale. The README also states the principle the maintainers work under, which is to serve the community of self-hosters, enthusiasts and hobbyists while keeping the project sustainable.

The licence is BSD-3-Clause with a LICENSE file at the root, and the README notes that sponsorship and donation buttons are available in the repository. Other governance files sit beside them: CONTRIBUTING.md, CODE_OF_CONDUCT.md, AI_POLICY.md, and a .mcp.json at the root, so the project has a written position on generated code and an MCP configuration committed next to the Go module. The last push to the default branch, main, was on 2026-09-29, six days after v0.29.4 was tagged.

## Conclusion

headscale fits one person or a small team that wants a WireGuard mesh without a SaaS control plane, and that will run the binary directly rather than behind a proxy. It does not fit a multi-tenant setup, because the stated design goal is a single tailnet for personal or small-organisation use. Verify first that the tag you check out matches the example configuration you copy, since the README warns that main can carry unreleased changes, and confirm that make test runs at all on your machine, since it needs five tools and a Docker daemon.

## FAQ

### What is a headscale?

It is a self-hosted, open source implementation of the Tailscale control server, written in Go and released under BSD-3-Clause. The control server is the component that exchanges Wireguard public keys, assigns client IP addresses, draws the boundaries between users, allows machines to be shared and exposes advertised routes, and headscale implements a single tailnet for personal or small-organisation use.

### What is the difference between Tailscale and headscale?

Tailscale operates the control server as a hosted service, while headscale is your own. The README notes that everything in Tailscale is open source except the GUI clients for Windows and macOS/iOS and the control server, which is the part headscale replaces. The client side is unchanged: you still run Tailscale clients against a headscale server.

### how to install headscale

The README gives no install command. It links the documentation at headscale.net/stable/ and, for contributors, nix develop followed by make build. It also states plainly that the project does not support or encourage running Headscale with a reverse proxy or in a container, even though development builds are published as container images and binaries.

### Is Headscale free to use?

Yes, it is open source under the BSD-3-Clause licence, with a LICENSE file at the repository root. The project asks for sponsorship or donations rather than charging, and it is not associated with Tailscale Inc., although one active maintainer is employed by Tailscale and is allowed to spend work hours on contributions that other maintainers review.

## Sources

- [Official README](https://github.com/juanfont/headscale#readme)
- [Project repository](https://github.com/juanfont/headscale)
- [Release notes](https://github.com/juanfont/headscale/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/juanfont-headscale
