profile picture

Hardening my homelab tailnet with Headscale grants

November 15, 2026 - tailscale headscale homelab security

Back when I started with headscale, the policy had a single rule: every node can reach every port on every other node (* to *:*). It had a FIXME next to it for way too long. 😅

Headscale 0.29 added support for grants, so I finally fixed it with grants instead of the deprecated acls.


Tags per access rule, not per machine

My first instinct was a tag for each kind of machine. That gets messy fast. Instead, a tag exists for each access rule, and a node gets a new tag only when it follows a new rule:

TagCarried byReached by
tag:metalBare metal hosts (Incus)Admin (SSH, Incus API)
tag:serverHeadscale VPS, containersAdmin (SSH)
tag:nasNASFamily (media), admin (SSH), all but bare metal (DNS)
tag:kvmJetKVMAdmin (every port)
tag:syncthingSyncthing containerSyncthing users (both ways)

Personal devices (laptops and phones) carry no tags. They belong to users, and users go into groups: group:admin (only me) and group:family.

Bare metal never sends traffic into the tailnet

The bare metal hosts run Incus, and some of the machines on them are LXC, so they share the host's kernel. A compromised host controls every container on it, and a compromised container could escalate to the host. So the hosts only receive traffic: admin SSH and the Incus API, nothing else.

A side effect I almost missed: my home DNS server is AdGuard on the NAS, which filters ads and other stuff. Bare metal can no longer reach it, and I don't want every lookup on those hosts to depend on it anyway (not to mention the nightmare of debugging it). So each bare metal host uses its own resolvers:

$ tailscale set --accept-dns=false

Servers and containers: SSH only

The VPS that runs Headscale and Pocket ID, and the containers on the bare metal hosts, share tag:server. They get Tailscale SSH from my devices and nothing else. A server can never reach a user device.

But there is always an exception: a container that runs Syncthing. Syncthing can open a connection from either side (my device or the container). So instead of opening tag:server toward my devices, that container gets its own tag on top, and Syncthing users get their own group:

// Syncthing checks device IDs, this only opens the port
{
  "src": ["group:syncthing"],
  "dst": ["tag:syncthing"],
  "ip": ["tcp:22000", "udp:22000"]
},
{
  "src": ["tag:syncthing"],
  "dst": ["group:syncthing"],
  "ip": ["tcp:22000", "udp:22000"]
}

Appliances and the rest

The NAS and the KVM are appliances: they can't run Tailscale SSH (or not the way I want), so they get specific ports instead:

// Media and photos for the family
{
  "src": ["group:family"],
  "dst": ["tag:nas"],
  "ip": ["tcp:443", "tcp:5001"]
},
// DNS for everyone, except bare metal
{
  "src": ["autogroup:member", "tag:server", "tag:kvm"],
  "dst": ["tag:nas"],
  "ip": ["tcp:53", "udp:53"]
},
// JetKVM web UI plus WebRTC on dynamic UDP ports
{
  "src": ["group:admin"],
  "dst": ["tag:kvm"],
  "ip": ["*"]
}

I used group:family instead of autogroup:member on purpose. Every user who logs in through the identity provider becomes a member, so with an explicit group a new user reaches only DNS, nothing else.

The NAS is the only appliance that sends traffic. It runs a Beszel hub that polls an agent on a bare metal host, so it gets one port toward tag:metal.

Scan the real flows before the cutover

Removing the catch-all rule could lock me out of my own services and machines. So before the cutover, I logged the real traffic on the tailnet. A small script adds a log-only nftables table on each host:

table inet ts_flow {
  chain prerouting {
    type filter hook prerouting priority -150; policy accept;
    iifname "tailscale0" ct state new log prefix "ts-flow "
  }
}

A few days later, it collects the kernel log lines and counts unique flows by node name. Things I learned along the way:

Tests in the policy, and a guarded deploy

Headscale runs tests and sshTests from the policy file against the live nodes. A wrong expectation fails with a clear message:

family@ -> tag:nas:443: expected ALLOWED, got DENIED

The tests cover what I allow and also what I deny:

"tests": [
  {
    "src": "tag:metal",
    "deny": [
      "tag:nas:53",
      "tag:nas:443",
      "tag:server:22",
      "tag:kvm:80"
    ]
  },
  {
    "src": "tag:syncthing",
    "accept": ["luis@:22000"],
    "deny": ["family@:22000"]
  }
],
"sshTests": [
  {
    "src": "luis@",
    "dst": ["tag:metal"],
    "accept": ["core", "provision"],
    "deny": ["root"]
  }
]

Since Headscale also runs the tests when it loads the policy, a failing test can stop the service. So the deploy now runs the same check first.

The uc below is Uncloud, which I have used for about a year to deploy containers to my machines. I will share more about how I set it up another time. 😉

policy-check:
	@yq '.configs.headscale_policy.content' services/headscale.yaml | \
	  uc exec -T headscale headscale policy check -f /dev/stdin

deploy: policy-check
	@uc deploy

To make sure the tests were doing anything, I broke a grant on purpose and watched them fail before trusting them.

Not everything went smoothly, though:

Verify both sides

After the deploy, I checked that everything I need still works (media, SSH, DNS, the KVM web UI, Syncthing), and that the denied flows are really denied. tailscaled logs every dropped packet, which is the best way to spot a flow you forgot:

Drop: TCP{100.64.0.10:55789 > 100.64.0.20:2222} 64 no rules matched

Grep for no rules matched, not drop: Tailscale's rate-limited log lines end in (N dropped) and add a lot of noise.

What's next?

The SSH rules for bare metal and the KVM move to check mode, which asks for an identity provider login every 12 hours. Then the long-lived pre-auth keys go away: new keys are single-use, carry one tag, and expire the next day.

The LXC containers are the weakest part of my setup, so after this change, I will move them to proper VMs. 😊