Skip to content

Prepare Headscale

Bootstrap API key

The operator authenticates with a bootstrap key it only reads; from it it mints its own working key (rotated at half-life) into the Secret <tailnet>-apikey. Create the bootstrap key with a long life and store it once:

headscale apikeys create --expiration 8760h

Create the Kubernetes Secret after installing the operator . Do not commit the plaintext key.

Policy: what must exist

Every egress/ingress node carries a tag, and the tag is the authorization. For each CR the policy must contain:

BlockEntryExample
tagOwnersthe CR’s tag"tag:egress-billing-dev": ["group:operator"]
autoApprovers.routeseach egress target as /32, approved for the router’s tag"10.0.10.20/32": ["tag:fabric"]
grantstag → target /32 on the target port{ "src": ["tag:egress-billing-dev"], "dst": ["10.0.10.20/32"], "ip": ["tcp:1521"] }
grantswho may reach an ingress{ "src": ["group:shop"], "dst": ["tag:ingress-shop"], "ip": ["tcp:5432"] }

You can maintain these by hand (policy mode file, the operator only reads the policy and reports NoGrant/NotApproved) or let the operator generate them (Operator-owned policy ). deploy/examples/policy-base.jsonc is a minimal base that works for both.

Two things always stay outside the policy: the router must advertise a route covering the target (--advertise-routes=10.0.10.20/32,… per host, or an aggregate such as 10.0.10.0/24; the generated /32 approval is then redundant but harmless), and the site firewall must allow the router’s address to the target port.

Policy mode

ModeWhere the policy livesWho writes it
file (default)policy.path, e.g. a ConfigMap mounted at /acl/policyyou, via Git; Headscale reloads on SIGHUP/restart
databasethe Headscale databaseheadscale policy set -f … or PUT /api/v1/policy — the operator in Operator-owned policy

Switching to database is described in Migrating from file mode .

PostgreSQL TLS

If Headscale uses PostgreSQL, set HEADSCALE_DATABASE_POSTGRES_SSL=verify-full, mount the server CA and point PGSSLROOTCERT at it (with CloudNativePG: the generated server CA and the -rw Service FQDN). Select the intended kube-context and the current database primary. Adjust the database role filter if the deployment does not use headscale:

kubectl --context <context> -n <headscale-ns> exec <db-primary> -c postgres -- \
  psql -X --no-password -v ON_ERROR_STOP=1 -qtAc \
  "select host(a.client_addr), s.ssl, s.version, s.cipher from pg_stat_activity a left join pg_stat_ssl s using (pid) where a.usename='headscale'"

Require at least one row and ssl=t in every row. An empty result is inconclusive, not a pass; false or NULL fails the transport check. Correlate client addresses with the running Headscale pods.

This proves encryption for the observed connections, not certificate-name or CA validation by itself. Check the live pod’s selected SSL mode, database hostname and CA mount without dumping credentials. Absence of TLS errors in logs is not a negative validation test; retained logs might omit startup. Test rejection of a wrong CA or hostname only in an isolated fixture, not by breaking a production database connection.

Preflight

The Tailnet controller probes the server’s capabilities on every reconcile (at least every five minutes; a failed probe retries after one minute). kubectl get tailnet shows the COMPAT, POLICY and DNS reasons, kubectl describe tailnet the messages. Detection uses the API response shape, not a version string; spec.headscale.version is an optional hint for diagnostics only.

ConditionMeaning
HeadscaleCompatible=True/CompatibleDatabase policy mode and POST /api/v1/policy/check are available
HeadscaleCompatible=False/IncompatibleFile policy mode, or no policy/check endpoint (Headscale < 0.29)
PolicyMode=True/DatabaseGET /api/v1/policy carries an updatedAt timestamp
PolicyMode=False/FileupdatedAt is null; Apply skips policy writes and route approvals (PolicyApplied=False/PolicyModeFile)
PolicyMode=Unknown/CannotVerifyThe response omits updatedAt
…=Unknown/ProbeFailedA request failed or returned an invalid body; check reachability, credentials and Headscale logs
SplitDNS=Unknown/CannotVerifyHeadscale has no DNS API; configure the displayed split zone yourself
UsersUnique=False/DuplicateTwo Headscale users share a name (status.userConflicts, IDs in the message); Headscale rejects every policy write until one is removed (headscale users destroy -i <id>)

The split-DNS hint is the literal zone after the last placeholder label of dns.ingressNameTemplate: {name}-{namespace}.prod-1.tailnet.example.com → prod-1.tailnet.example.com. That zone must not be the MagicDNS base_domain: the gateway serves it from the CR names (status.dnsName, status.hosts), while MagicDNS serves <hostname>.<base_domain> from the node name (status.hostname) — the two stay in sync only by accident and diverge on the first spec.hostname change. An empty policy database does not block bootstrap (key validation uses the API-key listing); DryRun publishes the rendered policy even in file mode.

Source: docs/how-to/install-headscale.md at v0.18.0 — found a mistake? Tell us.