# Headscale ACL Policy: What It Blocks, Tested

> A Headscale policy hid a database from a worker. A removed rule reached the client in 2.4 to 3.6 s, but in one run an open psql session kept writing.

Published: 2026-11-06. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/headscale-acl-policy

With a Headscale access policy loaded, a worker with no rule to the database had no status entry, DNS name or ping for it.
A removed rule reached the client in 2.4 to 3.6 s, but an open session can keep running.
I ran these in containers on Docker networks, so a real VPS and a provider reboot aren't covered.

I tested Headscale 0.29.7 and Tailscale 1.104 clients with the VPS and four machines running as containers on separate Docker networks.
`app` ran Ubuntu, and `worker` and `laptop` ran Alpine.
Certificates came from Caddy's internal CA and names from `/etc/hosts`, so real DNS, Let's Encrypt and real NAT traversal were not part of the test.
I also didn't run a provider reboot or any macOS, Windows or mobile client, so the restarts below were Headscale container restarts.

The stack is in [**my Headscale setup guide**](https://milanjovanovic.tech/blog/headscale-self-hosted-mesh-vpn), which installs everything in `/opt/headscale` and sets the policy path before any machine joins.
This is the full policy, `/opt/headscale/config/policy.hujson`:

```json
{
  "groups": {
    "group:admins": ["admin@"]
  },
  "tagOwners": {
    "tag:app": ["group:admins"],
    "tag:db": ["group:admins"],
    "tag:worker": ["group:admins"]
  },
  "nodeAttrs": [
    { "target": ["autogroup:member", "autogroup:tagged"], "attr": ["cache-network-maps"] }
  ],
  "grants": [
    { "src": ["group:admins"], "dst": ["tag:app", "tag:db", "tag:worker"], "ip": ["tcp:22"] },
    { "src": ["tag:app"], "dst": ["tag:db"], "ip": ["tcp:5432"] },
    { "src": ["tag:worker"], "dst": ["tag:app"], "ip": ["tcp:4222"] }
  ]
}
```

Compose mounts `./config` at `/etc/headscale`, so an existing install needs `policy.path: /etc/headscale/policy.hujson` in its config.
The policy gives `group:admins` SSH on `tcp:22` to `tag:app`, `tag:db` and `tag:worker`, lets `tag:app` reach `tag:db` on `tcp:5432`, and lets `tag:worker` reach `tag:app` on `tcp:4222`.
`laptop` joined as the admin user, so it's in `group:admins`.
The `nodeAttrs` block grants the outage cache that the setup guide covers.
[**The sample download**](https://milanjovanovic.tech/code/headscale-stack.zip) holds this file, and its `verify.mjs` reruns the allowed and denied paths between `app`, `db` and `worker`.

## What Does the Headscale Access Policy Block?

With no policy loaded, every machine can reach every port on every other machine, and the shipped config loads none.
Before I loaded a policy, `worker -> db:5432`, `worker -> db:22` and `db -> app:22` all connected, and [**Headscale's policy docs**](https://github.com/juanfont/headscale/blob/v0.29.7/docs/ref/policy.md) say the same.

With the policy above, I probed the allowed and denied paths:

```bash
nc -z -v -w 4 app 22        # from the laptop
nc -z -v -w 4 db 5432       # from the worker: "Name does not resolve", db is hidden from it
tailscale ping 100.64.0.2   # from the worker, with db's address: "no matching peer"
```

The results (trimmed):

```text
laptop  -> app:22  rc=0  Connection to app (100.64.0.1) 22 port [tcp/ssh] succeeded!
app     -> db:5432  rc=0  Connection to db (100.64.0.2) 5432 port [tcp/postgresql] succeeded!
worker  -> app:4222  rc=0  Connection to app (100.64.0.1) 4222 port [tcp/*] succeeded!
worker  -> db:5432  rc=1  nc: getaddrinfo for host "db" port 5432: Name does not resolve
worker  -> 100.64.0.1:22  rc=1  nc: connect to 100.64.0.1 port 22 (tcp) timed out: Operation in progress
laptop  -> 100.64.0.2:5432  rc=1  nc: connect to 100.64.0.2 port 5432 (tcp) timed out: Operation in progress
worker sees db: 0; ping: no matching peer
```

The exact error text varies by distro.

The worker has no rule to the database, so it has no status entry, DNS name or ping for it.
A capture on the database saw 0 packets.

A timeout to a hidden peer proves nothing, because the packet leaves through the default route.
Check the status entry and `tailscale ping` instead.

## When Should You Add Policy Tests?

Wait until every tagged node exists before you add tests, because they fail earlier with `resolved to no IP addresses`.
Add them after `grants` in `/opt/headscale/config/policy.hujson`, with a comma after the `grants` array:

```json
  "tests": [
    { "src": "tag:app", "accept": ["tag:db:5432"] },
    { "src": "tag:worker", "accept": ["tag:app:4222"], "deny": ["tag:db:5432"] },
    { "src": "group:admins", "accept": ["tag:db:22"], "deny": ["tag:db:5432"] }
  ]
```

Then, as root on the VPS:

```bash
test -s /opt/headscale/config/policy.hujson && docker exec headscale headscale policy check --file /etc/headscale/policy.hujson
PID=$(docker inspect --format '{{.State.Pid}}' headscale); [ "$PID" -gt 0 ] && kill -HUP "$PID" || echo "Headscale is not running, nothing reloaded"
docker logs --since 5s headscale 2>&1 | grep -i polic
```

You should see `Policy is valid`, then `Policy reload completed with changes`.
The second line reloads with `kill -HUP` on the PID, because Docker counts any `docker kill` as a manual stop.
Its check is there because a stopped container reports PID 0, and `kill -HUP 0` ended my shell.

## What Happens When the Policy File Is Bad?

A reload and a restart treat a bad file differently, so run the check before either:

| Policy file | On reload | On container restart |
| --- | --- | --- |
| Syntax error | Rejected, old rules stay | Headscale crash-loops, and tunnels keep working |
| A failing test | Rejected, old rules stay | Applied with a warning, and the port the test denied opened |
| Empty file (0 bytes) | No error, old rules stayed for the 40 s I watched | Everything allowed, with no error |

`policy check` catches the first two rows.
It reports an empty file as `Policy is valid`, so from `/opt/headscale` as root run `test -s config/policy.hujson` with it.
It also doesn't catch an unknown user.
I ran only `policy check` on that file, so I can't say what a reload or restart does with it.

If you switched to [**database mode for Headplane**](https://milanjovanovic.tech/blog/headplane-setup-without-docker-socket), the policy lives in the database and these file steps no longer apply.

## How Fast Does a Removed Rule Apply?

A reload that removed a rule reached the client in 2.4 to 3.6 s.
In 3 runs, I removed a rule that let `worker` reach `db:5432`, reloaded, and probed that port in a loop with a 1 s `nc` timeout.
The first failed probe came 2.4, 3.6 and 3.6 s after the signal.

With another rule still linking two machines, new connections stopped.
In one run, I changed the `app` to `db` grant from `tcp:5432` to `tcp:9`, so the two stayed peers.
An open `psql` session was still writing 180 s later, until `pg_terminate_backend` ended it.
This ran on `db`, and it ends every other `psql` session on that server:

```sql
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE application_name = 'psql' AND pid <> pg_backend_pid();
```

With the last rule gone, the peer left the map and writes stopped.
`psql` hung without exiting for the 26 s I watched, so end the session at the server as well.

## Summary

With no policy, every machine can reach every port on every other machine, so set the policy path before you join machines.
With the policy loaded, the worker had no status entry, DNS name or ping for the database, and a capture on the database saw 0 packets.

Run `policy check` and `test -s` before a reload or a restart, because a restart crash-looped on a syntax error and applied a policy with a failing test.
A removed rule reached the client in 2.4 to 3.6 s, and an open session can outlast it, so end the session at the server as well.

All of this ran in containers on Docker networks, and I didn't run a provider reboot.

The rest of the stack is in my [**Headscale guide**](https://milanjovanovic.tech/blog/headscale-self-hosted-mesh-vpn), and my [**self-hosting course**](https://milanjovanovic.tech/pragmatic-self-hosting) sets up private administration over Tailscale on a real server.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### Does Headscale allow all traffic by default?

Yes. With no policy loaded, every machine can reach every port on every other machine, and the shipped config loads no policy. Set a policy path in the config before you join machines.

### How do you check that a Headscale policy hides a machine?

Read the status entry and run tailscale ping with the hidden address. A timeout to a hidden peer proves nothing, because the packet leaves through the default route. In my test the worker had no status entry, DNS name or ping for the database.

### What happens if the Headscale policy file is broken?

A reload rejects a syntax error and keeps the old rules. A restart with the same file crash-loops Headscale, and tunnels keep working. A failing test is rejected on reload and applied on restart, so run policy check before either.

### Does removing a Headscale policy rule end open connections?

Not always. A reload reached the client in 2.4 to 3.6 s and new connections stopped, but with another rule still linking the two machines an open psql session was still writing 180 s later in one run. End the session at the server as well.
