Homelab
Proof

U2 / Networking and remote access

Headscale 0.29.7 with two Tailscale clients, no DERP relay

Tested on Headscale 0.29.7

Test result

Headscale 0.29.7 registered two official Tailscale 1.104.1 containers with a pre-auth key and they pinged each other over their tailnet IPs, directly. Without any DERP entry Headscale refused to start, and with a dead placeholder relay, blocking the direct UDP path left the two nodes unable to talk.

Line drawing: a small central control tower linked by thin lines to several devices arranged in a ring.

What happened

Headscale is a self-hosted control server for the Tailscale client. This test ran Headscale 0.29.7 and two official tailscale/tailscale 1.104.1 containers in one VM, joined them with a pre-auth key, and checked that they could reach each other. They could, with one caveat about timing and one about what happens when there is no relay.

Headscale 0.29.7 with two Tailscale clients joined by pre-auth key
MeasurementResult
Headscale container start to /health 2003.01 s
Two fresh clients: container start to both in state Running, 3 cycles7.88 (7.20 to 10.42) s median (range)
Two fresh clients: container start to first successful tailscale ping, 3 cycles10.68 (10.58 to 11.39) s median (range)
Nodes registered at the end (headscale nodes list)2
Path reported by tailscale ping over the tailnet IPdirect, 172.18.0.3:50660
Two fresh clients: container start to first successful plain ping over the tailnet IP, 3 cycles26.31 (20.92 to 27.38) s median (range)
Plain ping over the tailnet IP afterwards, 10 packets: avg RTT / loss0.999 ms / 20%
Headscale RAM, no nodes / two nodes (median of 12)11.0 MiB / 15.1 MiB
Each Tailscale client container RAM (median of samples)19.0 MiB

Join time includes starting the container and tailscaled. Headscale and both clients share one 4 vCPU, 3 GB VM on a Docker bridge network, client image tailscale/tailscale v1.104.1.

Registration was quick: both nodes reached the Running state in a median of 7.88 s from container start, and tailscale ping between them succeeded by a median of 10.68 s over a direct path on the Docker network. A plain ping to the other node’s 100.64.x.x address was slower to start working: first success came a median of 26.31 s after container start (range 20.92 to 27.38 s). The test did not isolate why. It ran with an unreachable placeholder relay in the DERP map (below), and it was not repeated with a working relay, so the delay cannot be attributed to the relay from this data. After the path was up, a 10-packet ping from node-a to node-b averaged 0.999 ms with 20% loss (2 packets lost), while the same ping from node-b to node-a averaged 0.659 ms with 0% loss (ping.a-to-b.txt, ping.b-to-a.txt). Both are single short runs.

Headscale itself is small: 11.0 MiB with no nodes and 15.1 MiB with two, and each client container used 19.0 MiB.

The DERP question

DERP is the relay Tailscale clients fall back on when a direct connection cannot be made. Headscale’s own example config (v0.29.7) ships urls: [https://controlplane.tailscale.com/derpmap/default], so a default install already points clients at Tailscale’s public DERP servers. This test removed that on purpose, to see what a self-hosted setup with no relay does, and started with the most literal version: derp.server.enabled: false, urls: [], paths: []. Headscale did not start. That result is about this stripped-down config, not about a default install. It exited with an error and Docker restarted it in a loop.

What happened without a reachable DERP relay
ScenarioResult
Config with derp.server.enabled false, urls [] and paths []Headscale did not stay up: restart_count=8 state=restarting exit_code=1. Last log line: Error: headscale ran into an error and had to shut down: initial DERPMap is empty, Headscale requires at least one entry
Placeholder DERP map (one unreachable node), direct UDP allowedBoth clients registered and tailscale ping succeeded over a direct path (see summary table)
Same, UDP dropped on node-b with iptables inside its containertailscale ping: 5 timeouts, then 'no reply'; plain ping over the tailnet IP: 100% loss. node-a status line for node-b: 'active; relay "lab"'. Health message: - Tailscale could not connect to the 'Lab placeholder, no relay running' relay server. Your Internet connection might be down, or the server might be temporarily unavailable.
Rules removed againtailscale ping succeeded again after 0.06 s

The DERP placeholder points at an address in 192.0.2.0/24 where nothing listens. The embedded DERP server was not tested: Headscale's example config says it requires an https server_url.

That is why the config in this test carries a placeholder map in derp.yaml: one region whose only node sits at a TEST-NET address where nothing listens. The Headscale docs say the embedded DERP server is added to the list of Tailscale’s free-to-use DERP servers once enabled; to avoid Tailscale’s relays you also set urls: [], and the same page warns that this leaves a single DERP server as “a single point of failure”. The example config says it requires an https server URL. This lab used plain HTTP on a Docker network, so the embedded server was not tested.

With the placeholder map the direct path worked. To see what a missing relay costs, the test dropped all UDP inside node-b’s container. tailscale ping timed out five times and ended with “no reply”, plain ping lost every packet, and tailscale status on node-a listed node-b as active; relay "lab" together with a health message that the relay could not be reached. The moment the rules were removed, tailscale ping worked again (0.06 s), although the first plain 5-packet ping after that still lost 3 of 5 packets (recovered-ping.txt). So the failure mode of “no DERP” is not an error at registration: both nodes register fine, and the first symptom shows up only when some pair of nodes cannot reach each other directly.

Steps that were tested

The user and key commands follow the getting-started page and run in the Headscale container; --reusable and --expiration come from headscale preauthkeys create --help (the page does not document them), and the TS_AUTHKEY and TS_EXTRA_ARGS variables come from the Tailscale container image, where the page uses tailscale up --authkey:

headscale users create lab
headscale preauthkeys create --user 1 --reusable --expiration 1h
# on each client, via the container environment:
#   TS_AUTHKEY=<key>  TS_EXTRA_ARGS=--login-server=http://headscale:8080

The clients run with /dev/net/tun, NET_ADMIN and TS_USERSPACE=false, so the kernel routes the tailnet addresses. An earlier run of this test left that variable unset and every plain ping failed; that run’s data was discarded and not used here.

lab/tests/headscale/files/compose.yml
Show the full file
services:
  headscale:
    image: headscale/headscale:0.29.7
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./${HS_CONFIG:-config.yaml}:/etc/headscale/config.yaml:ro
      - ./derp.yaml:/etc/headscale/derp.yaml:ro
      - headscale_lib:/var/lib/headscale
volumes:
  headscale_lib:
lab/tests/headscale/files/config.yaml
Show the full file
server_url: http://headscale:8080
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
derp:
  server:
    enabled: false
  urls: []
  paths:
    - /etc/headscale/derp.yaml
  auto_update_enabled: false
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite
dns:
  magic_dns: false
  override_local_dns: false
unix_socket: /var/run/headscale/headscale.sock
lab/tests/headscale/files/derp.yaml
Show the full file
# Placeholder DERP map: one region whose only node (TEST-NET-1 address) is not running. There is no relay in this lab.
regions:
  900:
    regionid: 900
    regioncode: lab
    regionname: Lab placeholder, no relay running
    nodes:
      - name: 900a
        regionid: 900
        hostname: derp.lab.invalid
        ipv4: 192.0.2.1
        stunport: -1
        stunonly: false
        derpport: 443

The script that produced every number, including the three join cycles (each cycle deletes the nodes in Headscale and starts two fresh containers):

lab/tests/headscale/hs.sh
Show the full file
#!/usr/bin/env bash
# Runs inside the VM from ~/hs. Raw outputs in ~/hs/raw.
set -u
cd "$HOME/hs"; mkdir -p raw
export COMPOSE_PROJECT_NAME=hs
TS=tailscale/tailscale:v1.104.1
NET=hs_default
HSC="docker exec headscale headscale"
now() { date +%s.%N; }
el() { python3 -c "print(round($2 - $1, 2))"; }
docker pull -q curlimages/curl:8.11.1 > /dev/null; docker pull -q "$TS" > /dev/null; docker pull -q headscale/headscale:0.29.7 > /dev/null
docker image inspect "$TS" headscale/headscale:0.29.7 --format '{{index .RepoTags 0}} {{.Id}}' > raw/images.txt

echo "== attempt 1: DERP disabled and no DERP map at all $(date -u +%T)"
HS_CONFIG=config.derp-empty.yaml docker compose up -d > raw/derp-empty.compose-up.txt 2>&1
sleep 20
docker inspect headscale --format 'restart_count={{.RestartCount}} state={{.State.Status}} exit_code={{.State.ExitCode}}' > raw/derp-empty.state.txt 2>&1
docker logs headscale > raw/derp-empty.logs.txt 2>&1
docker compose down -v > /dev/null 2>&1

echo "== start headscale with the placeholder DERP map $(date -u +%T)"
T0=$(now)
docker compose up -d > raw/compose-up.txt 2>&1
for _ in $(seq 1 60); do
  docker run --rm --network "$NET" curlimages/curl:8.11.1 -s -o /dev/null -w '%{http_code}' -m3 http://headscale:8080/health 2>/dev/null | grep -q 200 && break
  sleep 1
done
echo "ready_seconds $(el "$T0" "$(now)")" > raw/headscale-ready.txt
sleep 20
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' headscale; done > raw/headscale-idle.txt
$HSC version > raw/headscale-version.txt 2>&1
$HSC users create lab > raw/users-create.txt 2>&1
$HSC users list > raw/users-list.txt 2>&1
UID_=$($HSC users list -o json | jq -r '.[0].id')
$HSC preauthkeys create --user "$UID_" --reusable --expiration 1h -o json > raw/preauthkey.json 2>&1
KEY=$(jq -r '.key' raw/preauthkey.json)
[ -n "$KEY" ] && [ "$KEY" != null ] || { echo "no key"; cat raw/preauthkey.json; }

start_node() { # start_node <name>
  docker run -d --name "$1" --hostname "$1" --network "$NET" --cap-add NET_ADMIN --device /dev/net/tun \
    -e TS_USERSPACE=false -e TS_AUTHKEY="$KEY" -e TS_HOSTNAME="$1" -e TS_STATE_DIR=/var/lib/tailscale \
    -e TS_EXTRA_ARGS="--login-server=http://headscale:8080" "$TS" > /dev/null
}
running() { docker exec "$1" tailscale status --json 2>/dev/null | jq -e '.BackendState=="Running"' > /dev/null 2>&1; }

: > raw/cycles.tsv
for c in 1 2 3; do
  echo "== cycle $c $(date -u +%T)"
  A0=$(now)
  start_node node-a & start_node node-b & wait
  timeout 120 bash -c 'until docker exec node-a tailscale status --json 2>/dev/null | jq -e ".BackendState==\"Running\"" >/dev/null 2>&1 && docker exec node-b tailscale status --json 2>/dev/null | jq -e ".BackendState==\"Running\"" >/dev/null 2>&1; do sleep 0.25; done' || echo "cycle $c: nodes not Running in 120 s" >> raw/cycle-errors.txt
  A1=$(now)
  IPB=$(docker exec node-b tailscale ip -4 | head -1)
  timeout 120 bash -c "until docker exec node-a tailscale ping -c 1 --timeout 3s $IPB >/dev/null 2>&1; do sleep 0.25; done" || echo "cycle $c: no ping in 120 s" >> raw/cycle-errors.txt
  A2=$(now)
  # tailscale ping is answered by tailscaled itself; plain ICMP needs the WireGuard data path too, so time that separately
  timeout 120 bash -c "until docker exec node-a ping -c 1 -W 1 $IPB >/dev/null 2>&1; do sleep 0.25; done" || echo "cycle $c: no plain ping in 120 s" >> raw/cycle-errors.txt
  A3=$(now)
  echo -e "$c\t$(el "$A0" "$A1")\t$(el "$A0" "$A2")\t$(el "$A0" "$A3")" >> raw/cycles.tsv
  if [ "$c" -lt 3 ]; then
    docker rm -f node-a node-b > /dev/null
    for id in $($HSC nodes list -o json | jq -r '.[].id'); do $HSC nodes delete --identifier "$id" --force > /dev/null 2>&1; done
  fi
done

echo "== inspect $(date -u +%T)"
$HSC nodes list > raw/nodes-list.txt 2>&1
$HSC nodes list -o json > raw/nodes-list.json 2>&1
for n in node-a node-b; do
  docker exec $n tailscale version > raw/$n.version.txt 2>&1
  docker exec $n tailscale ip -4 > raw/$n.ip4.txt 2>&1
  docker exec $n tailscale status > raw/$n.status.txt 2>&1
  docker exec $n tailscale status --json > raw/$n.status.json 2>&1
done
IPB=$(cat raw/node-b.ip4.txt | head -1); IPA=$(cat raw/node-a.ip4.txt | head -1)
docker exec node-a ping -c 10 -i 0.2 "$IPB" > raw/ping.a-to-b.txt 2>&1
docker exec node-b ping -c 10 -i 0.2 "$IPA" > raw/ping.b-to-a.txt 2>&1
docker exec node-a tailscale ping -c 5 --timeout 5s "$IPB" > raw/tailscale-ping.txt 2>&1
docker exec node-a tailscale netcheck > raw/netcheck.txt 2>&1
docker exec node-a tailscale debug derp-map > raw/derp-map.txt 2>&1
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' headscale; done > raw/headscale-with-2-nodes.txt
for _ in $(seq 1 6); do docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' node-a node-b; done > raw/clients-stats.txt

echo "== block the direct path $(date -u +%T)"
docker exec node-b iptables -I INPUT -p udp -j DROP > raw/block.txt 2>&1
docker exec node-b iptables -I OUTPUT -p udp -j DROP >> raw/block.txt 2>&1
sleep 3
docker exec node-a tailscale ping -c 5 --timeout 4s "$IPB" > raw/blocked-tailscale-ping.txt 2>&1
docker exec node-a ping -c 5 -W 2 "$IPB" > raw/blocked-ping.txt 2>&1
docker exec node-a tailscale status > raw/blocked-status.txt 2>&1
docker exec node-b iptables -F INPUT; docker exec node-b iptables -F OUTPUT
R0=$(now)
timeout 240 bash -c "until docker exec node-a tailscale ping -c 1 --timeout 2s $IPB >/dev/null 2>&1; do sleep 1; done" && echo "recovered_after_seconds $(el "$R0" "$(now)")" > raw/recovered.txt || echo "not recovered within 240 s" > raw/recovered.txt
docker exec node-a tailscale ping -c 3 --timeout 3s "$IPB" > raw/recovered-tailscale-ping.txt 2>&1
docker exec node-a tailscale status > raw/recovered-status.txt 2>&1
docker exec node-a ping -c 5 -W 2 "$IPB" > raw/recovered-ping.txt 2>&1
docker compose logs --no-color --tail=40 headscale > raw/headscale-logs-tail.txt 2>&1
docker rm -f node-a node-b > /dev/null; docker compose down -v > /dev/null 2>&1
echo done > raw/DONE

What the docs say, and what this test did not check

Per the FAQ, Headscale implements a single tailnet, aimed at personal use or small organisations, and the project does not officially support running it in Docker; this test did anyway, as the stack in this project does. The same page says performance is not a design priority and warns about running Headscale on a machine that is also a tailnet member. The clients page says the project aims to support the last 10 Tailscale client releases; only 1.104.1 was used here. The features page lists DERP, MagicDNS, ACLs, subnet routers, exit nodes, Taildrop, Tailscale SSH and OIDC as supported, with OIDC groups in ACLs and some autogroups not supported, and leaves Funnel, Serve and flow logs without a stated status.

What this test did not cover

  • MagicDNS (it was turned off in the config), ACLs, subnet routers, exit nodes, Taildrop, Tailscale SSH and OIDC login. Each of these is listed as supported in the docs and none was exercised.
  • The embedded DERP server, a self-hosted DERP node, and any working relay. All “no DERP” results here come from a dead placeholder.
  • Real NAT: both nodes sat on one Docker bridge, so no hole punching was needed or tested.
  • Windows, macOS, iOS and Android clients, and any Tailscale version other than 1.104.1.
  • Throughput through the tailnet, key expiry, node removal while connected, upgrades from older Headscale versions, and PostgreSQL (SQLite was used).
  • Scale: two nodes only.

Sources and how this was made

lab/tests/headscale/run.sh
Show the full file
#!/usr/bin/env bash
# headscale: Headscale 0.29.7 control server plus two official Tailscale containers joined with a pre-auth key.
TEST_ID=headscale; VM_NAME=b-headscale
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
vm_start
capture_env
vm ssh 'rm -rf ~/hs; mkdir -p ~/hs/raw'
vm scp-to "$HERE/files/." hs/
vm scp-to "$HERE/hs.sh" hs/hs.sh
vm ssh 'cd ~/hs && nohup ./hs.sh > raw/hs.log 2>&1 &'
for _ in $(seq 1 120); do
  vm ssh 'test -f ~/hs/raw/DONE' 2>/dev/null && break
  sleep 10
done
vm scp-from hs/raw/. "$RAW/"
python3 -I "$HERE/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.