Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Advanced

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.

· 16 min read ·Duration: approx. 75 minutes
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
🍳 Recommendation Ad

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.

Go to netcup →

💶 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

All netcup vouchers →

Step by step

Step 1: Write the configuration

Headscale is configured through a single YAML file. Create the directories:

Terminal
mkdir -p /opt/headscale/config /opt/headscale/data && cd /opt/headscale

The 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.

YAML
# /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/16

server_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

Only set 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:

YAML
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: 24h

The 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:

YAML
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.112

The 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:

JSON
{
  "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

YAML
# /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: true

The 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).

Terminal
docker compose up -d
docker compose logs --tail 20

The logs should show this:

Ausgabe
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:8080

The 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:

Terminal
curl https://YOUR_DOMAIN/health
Ausgabe
{"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:

Terminal
docker compose exec headscale headscale users create home
docker compose exec headscale headscale users list
Ausgabe
ID | Name | Username | Email | Created
1  |      | home     |       | 2026-09-10 02:20:44

New devices register with a pre-auth key. Note that --user expects the ID from the list, not the name:

Terminal
docker compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 1h
Ausgabe
hskey-auth-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Caution

This key is full network access. Whoever holds it can attach their own device to your VPN. Keep the lifetime short (--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:

Terminal
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.list

The 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”.

Terminal
apt-get update
apt-get install -y tailscale
tailscale version
Ausgabe
1.104.1

Now the join. The decisive switch is --login-server, which points the client at your Headscale instead of Tailscale’s cloud:

Terminal
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.

Terminal
tailscale ip -4
Ausgabe
100.64.0.1

Step 5: A second node and MagicDNS

The second node is installed the same way, just with a different hostname and without --accept-dns=false:

Terminal
tailscale up \
  --login-server=https://YOUR_DOMAIN \
  --authkey=YOUR_PREAUTH_KEY \
  --hostname=laptop

On the server you now see both nodes:

Terminal
docker compose exec headscale headscale nodes list
Ausgabe
ID | 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:

Terminal
cat /etc/resolv.conf
Ausgabe
# resolv.conf(5) file generated by tailscale
nameserver 100.100.100.100
nameserver fd7a:115c:a1e0::53
search home.internal

100.100.100.100 is the built-in MagicDNS resolver. That makes the thing you actually wanted work:

Terminal
ping -c 2 home-server
Ausgabe
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.728/0.764/0.800/0.036 ms

The 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:

Terminal
tailscale ping home-server
Ausgabe
pong from home-server (100.64.0.1) via 172.19.0.1:41641 in 0s

If 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:

Ausgabe
pong from home-server (100.64.0.1) via DERP(home) in 1ms
direct connection not established

On 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:

Terminal
tailscale netcheck
Ausgabe
* 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:

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:

Terminal
docker compose exec headscale headscale policy check --file /etc/headscale/policy.json
Ausgabe
Policy is valid
Terminal
docker compose restart headscale

And 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:

Terminal
python3 -m http.server 9090 --bind 100.64.0.1

Then check from the laptop:

Terminal
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 BLOCKED
Ausgabe
OPEN
BLOCKED

With 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

A 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:

Terminal
tailscale set --advertise-exit-node

Offered is not the same as allowed. On the Headscale server you have to approve the route:

Terminal
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"
Ausgabe
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      | ::/0

Enable it on the client:

Terminal
tailscale set --exit-node=home-server

The easiest check is the public IP the client now shows on the internet – it has to be your server’s:

Terminal
curl -4 -s https://ifconfig.me

Behind it is routing table 52, which Tailscale puts in front of the normal table via policy routing:

Terminal
ip route show table 52
Ausgabe
default dev tailscale0
100.64.0.1 dev tailscale0
100.100.100.100 dev tailscale0

Turn 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:

Terminal
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 configtest

The 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:

Ausgabe
/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.json

Lose 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

Headscale is a reimplementation of the Tailscale control server and covers the open part of the protocol. Features of the commercial platform – Taildrop between different users, Tailscale SSH with session recording, the web interface, Funnel – are missing or only partly available. For a private mesh with a handful of devices nothing essential is lacking; anyone expecting a point-and-click admin interface will be disappointed.

You might also like