Headscale: Your Own Tailscale Network
Headscale 0.29.5 as your own control server behind Traefik: join nodes, MagicDNS, an embedded DERP relay, ACLs and an exit node – without anyone else's cloud.
Table of contents
WireGuard is wonderful right up until you add the fifth device: every peer needs every other peer’s key, every new IP has to be maintained everywhere, and behind two layers of NAT no connection comes up at all. Tailscale solves exactly that – and Headscale solves it without your device list living at someone else’s company.
What are we building?
We run Headscale 0.29.5 as our own control server behind Traefik. Two nodes running the official Tailscale client 1.104.1 attach to it and address each other by name via MagicDNS. The relay server (DERP) runs embedded in the same container, an ACL file governs who may reach whom, and finally the server acts as an exit node.
One thing matters for understanding all of it: Headscale is only the control plane. It distributes public keys, IP addresses and rules. The actual traffic then flows as a direct WireGuard connection between the devices – past the server. If Headscale goes down, existing nodes keep talking to each other; only new devices and rule changes need it.
The difference from a classic WireGuard setup is therefore: there, a star with the server in the middle and manual work per peer; here, a mesh in which devices find each other and connect directly even behind NAT.
Requirements
- A server with Traefik as a reverse proxy and a domain pointing at it – Headscale must be reachable over HTTPS
- WireGuard basics: you should know what a peer, a public key and an AllowedIP are
- Familiarity with Docker Compose
- A second device (laptop, second server, phone) that can join the network
VPS 1000 G12.5
4 vCores · 8 GB RAM · 128 GB SSD
from €14.50/month
Headscale itself is frugal – a VPS 1000 is plenty for the control plane and relay.
💶 1 month free:
Single use, valid for VPS 1000 G12.5. Redeem in the cart →
💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite
Step by step
Step 1: Write the configuration
Headscale is configured through a single YAML file. Create the directories:
mkdir -p /opt/headscale/config /opt/headscale/data && cd /opt/headscaleThe configuration is where most setups fail – so here it is in blocks, with reasoning. Replace
YOUR_DOMAIN with the domain Headscale should be reachable at, and YOUR_SERVER_IP with your
server’s public IPv4 address.
# /opt/headscale/config/config.yaml
server_url: https://YOUR_DOMAIN
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
trusted_proxies:
- 172.19.0.0/16server_url is the address every client will use later – it appears in every tailscale up call and
can only be changed afterwards with considerable pain. listen_addr binds to all interfaces of the
container so Traefik can reach it; from the outside, only Traefik is exposed.
noise.private_key_path is mandatory: without it Headscale doesn’t start and reports
headscale now requires a new noise.private_key_path field in the config file for the Tailscale v2 protocol. Headscale creates the key itself on first start.
trusted_proxies is the entry people tend to forget: without it, Headscale ignores the
X-Forwarded-For header and logs every login with Traefik’s IP. Put in the subnet of your Docker
proxy network – docker network inspect proxy --format '{{ range .IPAM.Config }}{{ .Subnet }}{{ end }}'
shows it.
Warning
trusted_proxies if a proxy really sits in front. Without one you are allowing every
client to forge its own source IP in your logs.On to the address ranges and the relay:
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: true
region_id: 999
region_code: "home"
region_name: "Own DERP"
verify_clients: false
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: YOUR_SERVER_IP
urls: []
paths: []
auto_update_enabled: false
update_frequency: 24hThe prefixes must stay within 100.64.0.0/10 and fd7a:115c:a1e0::/48 – the Tailscale client
hard-codes those ranges and breaks in subtle ways with anything else.
DERP is Tailscale’s relay: when two devices cannot establish a direct connection (symmetric NAT,
restrictive firewalls), traffic flows encrypted through a relay server. By default those are
Tailscale Inc.’s servers. With enabled: true you run your own, and urls: [] makes sure the
external list is never loaded – then not a single byte of your network leaves your own
infrastructure. verify_clients: false is required because that check needs a local tailscaled
socket, which does not exist inside the container.
Finally the database, rules and DNS:
disable_check_updates: true
node:
expiry: 0
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
log:
level: info
format: text
policy:
mode: file
path: /etc/headscale/policy.json
dns:
magic_dns: true
base_domain: home.internal
override_local_dns: true
nameservers:
global:
- 9.9.9.9
- 149.112.112.112The policy file from policy.path must exist at startup, otherwise Headscale aborts with
loading policy: open /etc/headscale/policy.json: no such file or directory. Until you write real
rules in step 7, create /opt/headscale/config/policy.json with this content – it allows
everything:
{
"acls": [
{ "action": "accept", "src": ["*"], "dst": ["*:*"] }
]
}SQLite is not a compromise here but the recommended choice – the Headscale developers build and test against SQLite; Postgres is only kept around for legacy reasons.
base_domain must not match server_url
base_domain is the zone for MagicDNS and must be a different domain from the one in
server_url. Otherwise Headscale refuses to start at all and reports:
server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable.
Pick something purely internal such as home.internal.Step 2: Start the stack
# /opt/headscale/compose.yaml
services:
headscale:
image: ghcr.io/juanfont/headscale:v0.29.5
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- "3478:3478/udp"
networks: [proxy]
labels:
- traefik.enable=true
- traefik.http.routers.headscale.rule=Host(`YOUR_DOMAIN`)
- traefik.http.routers.headscale.entrypoints=websecure
- traefik.http.routers.headscale.tls.certresolver=le
- traefik.http.services.headscale.loadbalancer.server.port=8080
networks:
proxy:
external: trueThe image tag is deliberately pinned to v0.29.5. Headscale is pre-1.0 and does change
configuration formats between minor versions – latest is a bad idea here.
The only directly published port is UDP 3478 for STUN. The DERP server needs it to help clients punch through NAT; HTTPS and the DERP channel itself go through Traefik. If you use UFW, 3478/udp has to be open – and if you have assigned a policy in the netcup firewall, it needs an inbound rule for UDP 3478 there as well. Without it the network still works, but every connection goes through the relay instead of directly (see step 6).
docker compose up -d
docker compose logs --tail 20The logs should show this:
INF no private key file at path, creating... path=/var/lib/headscale/noise_private.key
INF Opening database database=sqlite3 path=/var/lib/headscale/db.sqlite
INF no private key file at path, creating... path=/var/lib/headscale/derp_server_private.key
INF starting headscale commit=0f7d1e536bed9f01dd7161f7637af19bb0908335 version=v0.29.5
INF Clients with a lower minimum version will be rejected minimum_version=v1.80
INF stun server started at [::]:3478
WRN listening without TLS but ServerURL does not start with http://
INF listening and serving HTTP on: 0.0.0.0:8080The warning on the second-to-last line is correct and expected: Headscale speaks plain HTTP internally, Traefik does the TLS. Note the line above it too – clients older than 1.80 are rejected.
Now check from the outside that the chain holds:
curl https://YOUR_DOMAIN/health{"status":"pass"}If you get a certificate error or a 404 here, that is a Traefik problem and not a Headscale one – how to tell them apart is further down.
Step 3: Users and auth keys
Headscale assigns every node to a user. One user is plenty for a private network:
docker compose exec headscale headscale users create home
docker compose exec headscale headscale users listID | Name | Username | Email | Created
1 | | home | | 2026-09-10 02:20:44New devices register with a pre-auth key. Note that --user expects the ID from the list,
not the name:
docker compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 1hhskey-auth-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCaution
--expiration 1h), do not send it over chat or email, and drop --reusable if you
are only connecting a single device. After creation you see it in full exactly once –
preauthkeys list only shows the prefix from then on.Step 4: Join the first node
On the device that should join, install the official Tailscale client. The key comes as a binary
file, so no gnupg is needed:
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg \
-o /usr/share/keyrings/tailscale-archive-keyring.gpg
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list \
-o /etc/apt/sources.list.d/tailscale.list
chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg /etc/apt/sources.list.d/tailscale.listThe chmod looks redundant but is not: on hardened systems with UMASK 027, curl creates the
files with mode 0640 and apt can no longer read them. Details on that below under
“When it doesn’t work”.
apt-get update
apt-get install -y tailscale
tailscale version1.104.1Now the join. The decisive switch is --login-server, which points the client at your Headscale
instead of Tailscale’s cloud:
tailscale up \
--login-server=https://YOUR_DOMAIN \
--authkey=YOUR_PREAUTH_KEY \
--accept-dns=false \
--hostname=home-server--accept-dns=false matters on servers: otherwise tailscaled rewrites /etc/resolv.conf and
routes name resolution through the VPN. On a server that has to keep running, that is rarely what
you want. On laptops and phones you leave the option out – there MagicDNS is the whole point.
tailscale ip -4100.64.0.1Step 5: A second node and MagicDNS
The second node is installed the same way, just with a different hostname and without
--accept-dns=false:
tailscale up \
--login-server=https://YOUR_DOMAIN \
--authkey=YOUR_PREAUTH_KEY \
--hostname=laptopOn the server you now see both nodes:
docker compose exec headscale headscale nodes listID | Hostname | Name | MachineKey | NodeKey | User | Tags | IP addresses | Ephemeral | Last seen | Expiration | Connected | Expired
1 | home-server | home-server | [k54+k] | [71w+9] | home | | 100.64.0.1 | false | 2026-10-11 19:55:56 | N/A | online | no
| | | | | | | fd7a:115c:a1e0::1 | | | | |
2 | laptop | laptop | [QqjtX] | [7F2yB] | home | | 100.64.0.2 | false | 2026-10-11 19:56:34 | N/A | online | no
| | | | | | | fd7a:115c:a1e0::2 | | | | |On the laptop, tailscaled has meanwhile redirected the DNS resolver:
cat /etc/resolv.conf# resolv.conf(5) file generated by tailscale
nameserver 100.100.100.100
nameserver fd7a:115c:a1e0::53
search home.internal100.100.100.100 is the built-in MagicDNS resolver. That makes the thing you actually wanted work:
ping -c 2 home-server2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.728/0.764/0.800/0.036 msThe short name is enough; home-server.home.internal works just as well. Whether the connection is
truly direct or goes through the relay is answered by tailscale ping:
tailscale ping home-serverpong from home-server (100.64.0.1) via 172.19.0.1:41641 in 0sIf no direct connection can be established, the relay does its job and everything still works, just with a little more latency. Then it looks like this:
pong from home-server (100.64.0.1) via DERP(home) in 1ms
direct connection not establishedOn a netcup server the most common reason is a firewall policy in the SCP that blocks inbound UDP.
Step 6: Confirm your own relay is being used
The point of the embedded DERP server is that Tailscale’s infrastructure does not step in when things get difficult. Whether that holds is shown by the client:
tailscale netcheck* Nearest DERP: Own DERP
* DERP latency:
- home: 400µs (Own DERP)One region only, and it is yours. If the output further up says UDP: false, the client can’t
reach your STUN port – the case from step 2 where the netcup firewall doesn’t let UDP 3478 through. If you see a list with fra, ams or similar codes, the client
loaded the public DERP list – then urls: [] is missing from your configuration.
Step 7: ACLs – who may talk to whom
So far your policy file from step 1 lets every node do everything. As soon as real rules are in
it, default-deny applies: whatever is not explicitly allowed is dropped. A minimal, sensible rule –
it replaces the content of /opt/headscale/config/policy.json:
{
"groups": {
"group:household": ["home@"]
},
"acls": [
{
"action": "accept",
"src": ["group:household"],
"dst": ["group:household:22,80,443"]
},
{
"action": "accept",
"src": ["group:household"],
"dst": ["autogroup:internet:*"]
}
]
}Note the @ after the username – it is mandatory since policy format v2. Validate the file before
restarting:
docker compose exec headscale headscale policy check --file /etc/headscale/policy.jsonPolicy is validdocker compose restart headscaleAnd now verify the rule actually bites. Port 443 is allowed, 9090 is not. For the check to prove
anything, something has to listen on 9090 – otherwise it reports BLOCKED simply because nobody
answers. Start a quick test service on the server:
python3 -m http.server 9090 --bind 100.64.0.1Then check from the laptop:
timeout 5 bash -c "</dev/tcp/100.64.0.1/443" && echo OPEN || echo BLOCKED
timeout 5 bash -c "</dev/tcp/100.64.0.1/9090" && echo OPEN || echo BLOCKEDOPEN
BLOCKEDWith the allow-everything file from step 1 the second line would read OPEN – that’s how you know
the rule makes the difference. Stop the test service afterwards with Ctrl+C.
ICMP does not follow the port rule
ping to 100.64.0.1 still succeeds even though the rule only lists 22, 80 and 443. That is not a
bug: the Tailscale packet filter permits ICMP as soon as the source may reach any port on the
destination. Take the counter-test – remove the destination from the rules entirely and the ping
disappears too (2 packets transmitted, 0 received). A successful ping therefore only proves that
some rule matches, not which one.Step 8: The server as an exit node
An exit node sends a device’s entire internet traffic through the server – handy on someone else’s Wi-Fi. The server has to offer it:
tailscale set --advertise-exit-nodeOffered is not the same as allowed. On the Headscale server you have to approve the route:
docker compose exec headscale headscale nodes list-routes
docker compose exec headscale headscale nodes approve-routes --identifier 1 --routes "0.0.0.0/0,::/0"ID | Hostname | Approved | Available | Serving (Primary)
1 | home-server | 0.0.0.0/0 | 0.0.0.0/0 | 0.0.0.0/0
| | ::/0 | ::/0 | ::/0Enable it on the client:
tailscale set --exit-node=home-serverThe easiest check is the public IP the client now shows on the internet – it has to be your server’s:
curl -4 -s https://ifconfig.meBehind it is routing table 52, which Tailscale puts in front of the normal table via policy routing:
ip route show table 52default dev tailscale0
100.64.0.1 dev tailscale0
100.100.100.100 dev tailscale0Turn it off again with tailscale set --exit-node= and no value.
Subnet routes follow the same pattern: the server advertises an entire LAN with
tailscale set --advertise-routes=192.168.178.0/24, you approve it with approve-routes, and
clients pick it up with tailscale set --accept-routes. Without --accept-routes the route is
offered but never installed – that is the most common reason why “but the route is approved” still
does not work.
When it doesn’t work
Headscale doesn’t start and reports headscale now requires a new noise.private_key_path field. The noise: block with private_key_path: /var/lib/headscale/noise_private.key is missing from config.yaml (step 1). You don’t have to create the key yourself, Headscale does that on startup.
Headscale restarts in a loop, the log says loading policy: open /etc/headscale/policy.json: no such file or directory. policy.path points to a file that doesn’t exist yet. Create the allow-everything file from step 1 or your real rules from step 7 right away.
apt-get update reports The repository 'https://pkgs.tailscale.com/...' is not signed. Look at the line above it, which usually says Failed to parse keyring ... Permission denied (os error 13). The cause is a stricter UMASK (such as 027) on the system: curl -o then creates the key with mode 0640, and apt verifies signatures as the unprivileged user _apt, which cannot read the file. chmod 0644 /usr/share/keyrings/tailscale-archive-keyring.gpg /etc/apt/sources.list.d/tailscale.list fixes it immediately.
Headscale refuses to start and reports server_url cannot use the same domain as base_domain. dns.base_domain and the domain in server_url overlap. Headscale rejects that because the control server itself would then be resolved through MagicDNS and could make itself unreachable. Pick a separate, non-public zone such as home.internal for base_domain and validate the file before every restart with docker compose exec headscale headscale configtest.
The logs say listening without TLS but ServerURL does not start with http://. That is not a malfunction but the normal state behind a reverse proxy: Headscale speaks HTTP, Traefik handles TLS. You recognise a real problem by curl https://YOUR_DOMAIN/health not returning {"status":"pass"} – in that case it is Traefik, not Headscale.
A client will not connect and hangs during registration. Check the client version first with tailscale version: Headscale 0.29 rejects anything below 1.80, and the distribution packages of some LTS systems are older than that. If the version is fine, the next suspect is the pre-auth key – the default lifetime is one hour, and preauthkeys list shows you in the Expiration column whether it is still valid.
A subnet route is approved and installed on the client, yet the destination stays unreachable. If the target network is a Docker network on the same host, Docker blocks the traffic itself. Its filter chain contains -A DOCKER ! -i br-XXXX -o br-XXXX -j DROP, and packets arriving via tailscale0 hit exactly that rule. For container networks you therefore do not use a subnet route but publish the service through the reverse proxy as usual. For real LANs, check instead whether net.ipv4.ip_forward=1 is set on the subnet router.
Your problem is not listed? Search all error messages →
Maintenance & backups
Updates are not a set-and-forget affair with Headscale. The project is pre-1.0, and configuration keys and the policy format do change between minor versions. Read the release notes before every jump, especially the line about the minimum client version, and proceed like this:
cd /opt/headscale
cp -a data data-backup-$(date +%F)
sed -i 's/headscale:v0.29.5/headscale:vNEW_VERSION/' compose.yaml
docker compose pull && docker compose up -d
docker compose exec headscale headscale configtestThe database migration runs automatically at startup – hence the copy beforehand. The Tailscale client on your nodes, by contrast, updates through apt and may happily run ahead.
Three things need backing up, and two of them are irreplaceable:
/opt/headscale/data/db.sqlite # nodes, users, routes
/opt/headscale/data/noise_private.key # without it no client recognises you
/opt/headscale/data/derp_server_private.key
/opt/headscale/config/ # config.yaml + policy.jsonLose noise_private.key and every device has to register again. Add the directory to your
Restic backup. Because SQLite with write-ahead logging spans several
files, stop the container for the backup – docker compose stop, back up, docker compose start.
That costs a few seconds of control-plane downtime; existing connections between nodes keep running
throughout.
Realistic effort: after the initial setup it is a few minutes a month – review the node list,
remove old devices with headscale nodes delete, clean up expired keys. Plus a deliberate slot each
quarter for the Headscale update, precisely because that one should not run unattended.
Cleaning up is part of it. Devices you only need once get a key created with
preauthkeys create --ephemeral – such nodes disappear on their own after 30 minutes of inactivity
(configurable via node.ephemeral.inactivity_timeout). Everything else stays in the list until you
delete it, and every dead entry is a key that is theoretically still valid.
What Headscale cannot do
Send feedback: feedback@serverkueche.de
You might also like


