Hardening my homelab tailnet with Headscale grants
November 15, 2026 -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:
| Tag | Carried by | Reached by |
|---|---|---|
tag:metal | Bare metal hosts (Incus) | Admin (SSH, Incus API) |
tag:server | Headscale VPS, containers | Admin (SSH) |
tag:nas | NAS | Family (media), admin (SSH), all but bare metal (DNS) |
tag:kvm | JetKVM | Admin (every port) |
tag:syncthing | Syncthing container | Syncthing 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=falseServers 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:
- Docker publishes ports with DNAT, so those packets take the forward path and an input chain never sees them. My first smoke test missed every flow to the VPS's web server because of this.
- LXC containers don't log by default. The host needs
net.netfilter.nf_log_all_netns=1, and the lines land in the host's journal. - The kernel logs IPv6 in full form, while Headscale lists the short one
(
fd7a:115c:a1e0::8). I had to normalize them before matching names. - Syncthing was noisy. A bunch of "connections" to random high ports were ACK packets: replies to connections the other side opened, picked up after conntrack forgot about them. That's how I found Syncthing dialing from the container to my laptop.
- Long-lived connections like Beszel never show up. Use
sson each host to capture those.
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:
- Tests fail on a tag with no nodes. Headscale reports
destination resolved to no IP addresses. It does not pass quietly, which is good, but it means you add the tag, deploy, retag the node, and then add the tests. - Retagging before moving the SSH rule. The SSH rule still pointed to the old tag when I retagged the Headscale VPS, so Tailscale SSH denied me. And the deploy tool reaches that VPS through Tailscale SSH... A separate OpenSSH break-glass path saved me. Lesson: deploy the rule first, retag after.
headscale nodes tagreplaces the tag list. The help text says "tags to add", but it replaces them. To add one, pass every tag in one call.- The deploy tool connected as
root. The new policy only allowscoreandprovisionon servers. I caught it before the deploy this time and moved the tool tocore, which is in the right group anyway. tailscale setdoesn't take--advertise-tags, onlytailscale updoes, and that one wants every flag restated. Since Headscale ignores tags that no one owns, I left that for the next rebuild.
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. 😊