Headscale
Headscale is a control server for Tailscale clients: it holds the node registry,
the keys and the access policy of a tailnet, so that the tailnet is run by you
instead of for you. The clients are Tailscale’s own apps; the control server is
not theirs — upstream states it is not associated with Tailscale Inc. The VPN
that access-vpn admits can be this one. One container, SQLite and its keys in
volumes/lib.
Last run on a host 8 Nov 2026
Pinned at v0.29.7 (released 30 Oct 2026) · the newest upstream release
In the upstream images: 0 CRITICAL · 4 HIGH with a published fix — per image
To try it: Docker, nothing else. See Try it locally.
To deploy it behind the proxy: Traefik running, a domain, and a network address your clients join from.
Try it locally
Section titled “Try it locally”On your own machine, with no proxy, domain or certificate. From core/headscale/:
cp .env.local.example .env.localops/init.shsudo chown -R 1000:1000 volumes/libdocker compose -f docker-compose.local.yml --env-file .env.local up -ddocker compose -f docker-compose.local.yml --env-file .env.local \ exec headscale /ko-app/headscale users create opsThe control server answers on http://localhost:8080. The local stack is for
seeing it run and working the CLI: a client registers only against an https
server URL, which the installation below provides. Stop it again with the same
command and down.
Installation
Section titled “Installation”From core/headscale/:
-
Copy the environment file:
Terminal window cp .env.example .envVariable What it is APP_TRAEFIK_HOSTthe address every node is told to come back to — changing it later means registering every node again APP_TAILNET_DOMAINthe DNS suffix inside the tailnet: laptopbecomeslaptop.tailnet.internal. It must not containAPP_TRAEFIK_HOSTAPP_UID/APP_GIDthe owner of volumes/lib—1000:1000 -
Create the data directory for the user Headscale runs as:
Terminal window ops/init.shsudo chown -R 1000:1000 volumes/lib -
List
headscale-edgeinEDGE_NETWORKSincore/traefik/.env, then render and restart Traefik — see edge networks. -
Start it and create a user and a pre-auth key.
--usertakes the numeric ID fromusers list, not the name:Terminal window docker compose up -ddocker compose exec headscale /ko-app/headscale users create opsdocker compose exec headscale /ko-app/headscale users listdocker compose exec headscale /ko-app/headscale preauthkeys create --user 1 --expiration 1h -
Join a client from a range the route admits:
Terminal window tailscale up --login-server https://<APP_TRAEFIK_HOST> --authkey <the key> -
Copy
volumes/lib/noise_private.keysomewhere this host cannot reach. It is generated on the first start and is the tailnet’s identity.
There is no password and no setup wizard. Nodes authenticate with the pre-auth keys you issue, which expire.
Verify
Section titled “Verify”docker compose psdocker compose exec headscale /ko-app/headscale nodes listThe client you joined is listed and online. From it, tailscale status shows the
other nodes, and tailscale ping <node> reaches one.
Going further
Section titled “Going further”Letting clients in
Section titled “Letting clients in”The route ships access-trusted: reachable from the ranges TRUSTED_RANGES
declares in core/traefik/.env, and nowhere else. A node needs the server to
join; afterwards it keeps working from anywhere. access-vpn cannot work here —
this server would be that VPN’s control plane.
| Approach | When it fits |
|---|---|
stay on access-trusted |
every node joins from a declared range |
| an IP allow-list in front | you know the addresses nodes register from |
| an SSH tunnel for the join | one node, once, without changing the policy |
access-public |
nodes join from anywhere; a pre-auth key or an OIDC sign-in carries it, and the endpoint is not a user interface |
Your own relay
Section titled “Your own relay”Two clients talk directly when they can. When NAT or a firewall stops them, the
traffic goes through a DERP relay — by default Tailscale’s, from the map
Headscale fetches at controlplane.tailscale.com. derp.yml turns on
Headscale’s own:
docker compose -f docker-compose.yml -f derp.yml up -d| Tailscale’s relays (default) | Your own (derp.yml) |
|
|---|---|---|
| Traffic content | end-to-end encrypted; the relay sees none of it | the same |
| Metadata | Tailscale sees which endpoints relay, when, how much | nobody outside your host |
| Host ports | none | 3478/udp — STUN is not HTTP, so Traefik cannot route it |
| Dependency | relays operated by someone else | yours to keep running |
The STUN port binds to APP_DERP_STUN_BIND, which ships 0.0.0.0. Narrow it to
one interface, or filter it in front of the host — see
firewalls. The relay answers Tailscale clients only; a
plain STUN probe gets no answer. tailscale debug derp from a client checks it.
Hardening
Section titled “Hardening”Non-root, read-only, every capability dropped. Metrics and gRPC stay on loopback —
the metrics endpoint has no authentication. logtail, which upstream points at
Tailscale’s logging service, is off, and so are update checks. OIDC sign-in is
supported by Headscale and reads its client secret from a file
(oidc.client_secret_path); the stack does not configure it.
Backup
Section titled “Backup”| The identity | volumes/lib/noise_private.key — lose it and every node registers again. Keep a copy off this host |
| The registry | volumes/lib/db.sqlite — nodes, users, pre-auth keys, policy. Stop the container before copying |
| The relay key | volumes/lib/derp_server_private.key — only with derp.yml; a new one is harmless |
| Rebuildable | config/config.yaml is in git |
For borgmatic:
source_directories: - /srv/docker/core/headscale/volumes/libCopying db.sqlite while Headscale runs leaves a half-written file beside its
write-ahead log; stop the container for the copy. Restore: put volumes/lib
back, owned by 1000:1000, and start. Nodes reconnect on their own as long as
the noise key and the host name are the ones they registered against.
Updates
Section titled “Updates”At 0.x the configuration format changes between minor releases.
-
Read the release notes and the breaking changes in the changelog.
-
Raise
APP_TAGin.env, then test the configuration against the new version before starting it:Terminal window docker compose run --rm headscale configtest -
Terminal window docker compose pull && docker compose up -ddocker compose exec headscale /ko-app/headscale nodes listEvery node is still registered.
Troubleshooting
Section titled “Troubleshooting”Headscale refuses to start. Check first whether APP_TAILNET_DOMAIN
contains APP_TRAEFIK_HOST; Headscale refuses a control server
name inside the tailnet suffix. Choose a suffix outside it.
preauthkeys create fails with strconv.ParseUint. --user was given a
name. Use the numeric ID from users list.
A client cannot join. It is not in a range the route admits — see Letting clients in.
Headscale exits with a permission error on volumes/lib. The directory is
not owned by APP_UID:APP_GID.
Repository files
Section titled “Repository files”core/headscale/README.md— architecture, letting clients in, the relaycore/headscale/config/config.yamlcore/headscale/derp.yml— the embedded relaycore/headscale/.env.examplecore/headscale/UPSTREAM.md— maturity and upgrade checklist
Official sources
Section titled “Official sources”© 2026 SecDockBlueCode Apache-2.0Site content CC BY-NC 4.0