Run the gateway (subnet router + split-DNS)
Optional. A TailnetGateway is the operator-managed subnet router and resolver in one Pod: kernel Tailscale advertising
routes.lan ∪ service.tailnetLBCIDR ∪ {egress targets}/32, and a CoreDNS serving the hosts file of
DNS with CoreDNS
as the authoritative zone. It is fresh-only — a new node with a persistent identity; an existing
router is never adopted. The complete worked example (manifest, LB pool, iBGP, split-DNS, site) is
the worked example
; the contract
(receipts, immutability, deletion) is the gateway reference
.
Prerequisites
| Requirement | Why | Check |
|---|---|---|
/dev/net/tun on the nodes and net.ipv4.ip_forward=1 visible in the Pod network namespace (Talos inherits it from the host; elsewhere allow the namespaced net.ipv4.ip_forward unsafe sysctl) | kernel Tailscale; an unprivileged init container only checks and fails startup otherwise | kubectl -n tailnet-system logs prod-1-gw-0 -c kernel-preflight after the first start |
Pod Security admission in the operator namespace permits NET_ADMIN (level privileged, or an exemption) | the Tailscale container needs exactly that capability, no host network, no privilege escalation | kubectl get ns tailnet-system -o jsonpath='{.metadata.labels}' |
A LoadBalancer implementation with a pool whose selector matches service.poolLabels and whose CIDR contains service.loadBalancerIP | the LB IP is the split-DNS nameserver peers dial; tailnetLBCIDR is advertised so peers reach every IP of the pool | kubectl -n tailnet-system get svc prod-1-gw shows an EXTERNAL-IP |
A free, stable ClusterIP for dns.serviceClusterIP | the cluster CoreDNS forwards the zone to it; immutable | kubectl get svc -A -o jsonpath='{.items[*].spec.clusterIP}' | tr ' ' '\n' | grep 10.96.0.54 prints nothing before creation |
Tailnet.spec.policy.mode: Apply with the gateway tag in routeApprovers and tagOwners set — or a hand-maintained policy with matching tagOwners and autoApprovers.routes | routes are approved automatically only when the operator owns the policy | kubectl get tailnet corp shows POLICY=Applied |
Headscale dns.nameservers.split for the zone → the LB IP (manual; Headscale has no DNS API) | peers must ask the gateway for *.<zone> | Tailnet reports SplitDNS=Unknown/CannotVerify, by design |
| Backup of the state Secret and the receipt ConfigMaps | the node identity lives there; without them the gateway is a new node | State, backup and loss |
Order
- Pool and (if used) BGP for the tailnet LB range exist and announce.
Tailnet.spec.policyisApply(Apply ) or the policy carries the tag and routes by hand.- Apply the
TailnetGatewaywithsuspend: false(worked example ). Within about a minute:kubectl get tailnetgateway→READY=True;RoutesInSync=True/Approvedafter the next policy reconcile (≤ 5 min). - Set the Headscale split-DNS entry; add the cluster CoreDNS
forwardfor the zone todns.serviceClusterIP(Wire the cluster CoreDNS form, upstream = the gateway’s DNS Service). Repeat both for everydns.extraZones[]zone the gateway serves for a legacy network (Extra zones for legacy networks ). - Declare the LAN as a
TailnetSite(Declare a site network ) so overlaps are blocked and the site has a status. - Verify from both sides: a Pod resolves
api.<zone>through the cluster CoreDNS; a tailnet peer resolvesrouter.<zone>through split-DNS and reaches192.0.2.1through the gateway.
Done when Ready=True, RoutesInSync=True/Approved, ServiceReady=True/LoadBalancerAssigned,
WorkloadCurrent=True/PodCurrent, and the alert TailnetGatewayFreshBlocked is silent.
ClusterDNSForward and StaleNodes stay Unknown/ExternallyManaged: they are yours.
Afterwards
The operator never restarts the gateway Pod. Image and route changes are written into the
StatefulSet template and applied when you delete prod-1-gw-0 (WorkloadCurrent=False/PodTemplateStale
until then). Rollback of the operator keeps the gateway running — see the
rollback table
; a downgrade below 0.11.0 is not supported for an
existing gateway. Deleting the CR with the default deletionPolicy: Retain keeps node, Secret and
resources; Delete is the explicit teardown (deletion
).
Worked example
prod-1 sits in a datacenter LAN 192.0.2.0/24 (router 192.0.2.1). Tailnet peers must reach
that LAN, the cluster’s tailnet-facing LoadBalancer IPs, and every egress target the operator
manages — and resolve *.prod-1.tailnet.example.internal from the gateway. The
cluster already runs Cilium with an IP pool and iBGP to the LAN router.
A TailnetGateway is one kernel-mode Tailscale Pod (subnet router, NET_ADMIN, no host
network) plus a CoreDNS that serves the operator’s hosts file as an authoritative zone. The CR is
fresh-only: it creates a new node with a persistent identity and never adopts, updates or
replaces an existing router (reference
). One-time wiring (kernel
prerequisites, LB pool, Headscale split-DNS, cluster CoreDNS forward) is in
the one-time wiring above
; this recipe is the manifest and
how to read it.
The gateway
apiVersion: tsunaro.io/v1alpha1
kind: TailnetGateway
metadata:
name: prod-1-gw # cluster-scoped; also names the LoadBalancer Service
spec:
suspend: false # default true: nothing is created until you opt in
deletionPolicy: Retain # CR deletion keeps node, Secret and resources (Delete = teardown)
tailnetRef: { name: corp }
hostname: prod-1-gw # Headscale node name; immutable
tags: [tag:fabric] # default; must be in policy.routeApprovers for auto-approval
stateSecretRef: { name: prod-1-gw-state } # created empty by the operator, holds the node identity
image: ghcr.io/tailscale/tailscale:v1.102.4 # kernel tailscale; a version tag or digest, never :latest
routes:
lan: [192.0.2.0/24] # the site LAN; 1–128 IPv4 prefixes
advertiseEgressTargets: true # default: every literal TailnetEgress target becomes a /32 route
service:
loadBalancerIP: 198.51.100.17 # the split-DNS nameserver address peers will use; immutable
tailnetLBCIDR: 198.51.100.0/24 # the whole LB pool is advertised, so peers reach every LB IP
poolLabels:
io.cilium/lb-pool: tailnet # how your LB implementation picks the pool (labels on the Service)
dns:
zone: prod-1.tailnet.example.internal # = the split zone of Tailnet.spec.dns.ingressNameTemplate
serviceName: prod-1-gw-dns # in-cluster DNS Service for the CoreDNS forward
serviceClusterIP: 10.96.0.54 # stable, must be free in the Service CIDR; immutable
image: docker.io/coredns/coredns:1.14.7
staticRecords:
- name: api # api.prod-1.tailnet.example.internal
target: kube-api-platform.prod-1.tailnet.example.internal # CNAME to a TailnetIngress name (agents change IP; never pin)
- name: router # router.prod-1.tailnet.example.internal
address: 192.0.2.1 # A record for a fixed LAN host
wildcardAddress: 198.51.100.80 # *.prod-1.… → shared Envoy gateway (ingress over HTTPS); dynamic names winWhat the operator does with it, in order: render and validate; create the empty state Secret;
mint a tag-bound 24 h pre-auth key; create ServiceAccount, Role, RoleBinding, PDB, three
Services (prod-1-gw LoadBalancer :53, prod-1-gw-dns ClusterIP :53, prod-1-gw-headless), the
-coredns ConfigMap and the StatefulSet (nine objects, StatefulSet last); write the immutable -fresh receipt; then report Ready once the Pod runs,
the node is online and advertises the planned routes. The advertised set is
routes.lan ∪ tailnetLBCIDR ∪ {egress targets}/32 — here 192.0.2.0/24, 198.51.100.0/24 and,
from Reach a database at the site
, 10.0.10.20/32. When the Tailnet owns its
policy (policy.mode: Apply) the policy controller renders tag:fabric into tagOwners and every
route into autoApprovers.routes and approves them within one policy reconcile (≤ 5 min); otherwise
approve in Headscale.
Turn advertiseEgressTargets off when every egress host already lies inside routes.lan:
covered /32s are not deduplicated, and each new or deleted TailnetEgress would mark the Pod
template stale until you delete the Pod.
Site-provided pieces
The operator configures none of these; it only depends on them.
LB pool. The gateway’s Service carries the labels from service.poolLabels; your pool selects on
them, and the default pool excludes them so ordinary Services never land in the tailnet range:
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata: { name: tailnet }
spec:
blocks: [{ cidr: 198.51.100.0/24 }]
serviceSelector: { matchLabels: { io.cilium/lb-pool: tailnet } }
---
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata: { name: default }
spec:
blocks: [{ cidr: 198.51.101.0/24 }]
serviceSelector:
matchExpressions: [{ key: io.cilium/lb-pool, operator: NotIn, values: [tailnet] }]Cloud load-balancer controllers. On clusters that run a cloud controller manager
(Hetzner, AWS, GCP, …) a LoadBalancer Service without a class is claimed by the cloud
provider, which provisions an external load balancer, usually cannot carry UDP/53 and
never hands out an address from your in-cluster pool: the gateway stays
ServiceReady=False/LBPending and peers get no resolver. Hand the Service to the
in-cluster implementation explicitly:
service:
loadBalancerIP: 198.51.100.17
loadBalancerClass: io.cilium/l2-announcer # or io.cilium/bgp-control-plane, metallb.io/metallb, …The class is part of the pinned identity: add it before the first reconcile, or recreate the
gateway with deletionPolicy: Delete. Bare-metal clusters without a cloud controller do not
need it.
iBGP. Tailnet peers reach 198.51.100.0/24 through the gateway (it is an advertised route); the
LAN reaches those addresses only if the nodes announce them. With Cilium’s BGP control plane,
one instance in the site ASN peering with the LAN router (MetalLB or kube-vip speakers do the same
job):
apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata: { name: site }
spec:
bgpInstances:
- name: lan
localASN: 65010
peers:
- name: router
peerASN: 65010 # same ASN = iBGP
peerAddress: 192.0.2.1
peerConfigRef: { name: router }
---
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata: { name: router }
spec:
families:
- afi: ipv4
safi: unicast
advertisements: { matchLabels: { advertise: lb } }
---
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata: { name: lb, labels: { advertise: lb } }
spec:
advertisements:
- advertisementType: Service
service: { addresses: [LoadBalancerIP] }
selector: { matchExpressions: [{ key: io.cilium/lb-pool, operator: Exists }] }Headscale split-DNS. Peers resolve the zone at the gateway’s LoadBalancer IP (Headscale has no
API for this; SplitDNS=Unknown/CannotVerify on the Tailnet is the reminder):
dns:
nameservers:
split:
prod-1.tailnet.example.internal: [198.51.100.17]Cluster CoreDNS. Pods ask kube-system CoreDNS; forward the zone to the gateway’s ClusterIP
Service (or to the resolver from Bring your own resolver
):
prod-1.tailnet.example.internal:53 {
errors
cache 30
forward . 10.96.0.54 { prefer_udp }
}Declare the LAN as a site
The gateway advertises 192.0.2.0/24; a TailnetSite makes that network a first-class object with
Advertised/Approved/Ready, metrics and the overlap guard of Declare a site network
.
advertisedBy is omitted, so the advertiser tags default to policy.routeApprovers — the gateway’s tag:
apiVersion: tsunaro.io/v1alpha1
kind: TailnetSite
metadata: { name: prod-1-lan }
spec:
tailnetRef: { name: corp }
prefixes: [192.0.2.0/24]
owner: platform
description: prod-1 datacenter LAN, advertised by prod-1-gwDo not list the LAN or the LB CIDR in Tailnet.spec.reservedNetworks — those are routes the
gateway advertises, so they are site material, not protected space.
Check
kubectl get tailnetgateway prod-1-gw
# NAME HOSTNAME SUSPENDED READY
# prod-1-gw prod-1-gw false True
kubectl get tailnetgateway prod-1-gw -o jsonpath='{.status.nodeId} {.status.ipv4} {.status.serviceIP}{"\n"}{range .status.conditions[*]}{.type}={.status}/{.reason}{"\n"}{end}'
# 42 100.64.0.17 198.51.100.17
# Ready=True/FreshRuntimeReady RoutesInSync=True/Approved WorkloadCurrent=True/PodCurrent ServiceReady=True/LoadBalancerAssigned
kubectl -n tailnet-system exec deploy/headscale -- headscale nodes list-routes
# prod-1-gw: 192.0.2.0/24, 198.51.100.0/24, 10.0.10.20/32 — all approved
kubectl -n billing exec deploy/app -- getent hosts api.prod-1.tailnet.example.internal # in-cluster path
# from a tailnet peer:
dig +short router.prod-1.tailnet.example.internal # 192.0.2.1 via split-DNS
kubectl get tailnetsite prod-1-lan # READY=Ready| Symptom | Meaning | Fix |
|---|---|---|
Ready=False/Provisioning for minutes | Pod not yet running | kubectl -n tailnet-system describe pod prod-1-gw-0: image pull, kernel preflight (/dev/net/tun, ip_forward), Pod Security admission |
Ready=False/FreshBlocked | bootstrap blocked: key expired before registration, receipt or template drift, a pool key colliding with an operator label, or children of an earlier bootstrap that stopped halfway | alert TailnetGatewayFreshBlocked; runbook OPERATIONS
. Leftovers: set deletionPolicy: Delete, delete the CR, recreate it — the teardown removes everything carrying the Gateway’s label |
RoutesInSync=False/NotApproved | advertised but not approved | policy.mode: Apply with the tag in routeApprovers, or approve in Headscale |
ServiceReady=False/LBPending | no LoadBalancer address | pool CIDR contains loadBalancerIP, selector matches poolLabels, LB controller healthy; Ready does not wait for this |
WorkloadCurrent=False/PodTemplateStale | image or route change written to the template | delete Pod prod-1-gw-0 when convenient; the running Pod keeps serving |
WorkloadCurrent=False/WorkloadUpdateBlocked | an IPv6 route, a tailnetLBCIDR that no longer contains the LB IP, or an unverified StatefulSet | revert the spec; IPv6 routes are not supported yet |
ClusterDNSForward, StaleNodes Unknown/ExternallyManaged | by design | the CoreDNS forward and old Headscale nodes are yours |
| peers resolve nothing | split-DNS or route | nameservers.split points at 198.51.100.17; 198.51.100.0/24 approved; dig @198.51.100.17 from a peer |
After creation
| Change | Effect |
|---|---|
image, dns.image, routes.*, service.tailnetLBCIDR, egress targets | written into the StatefulSet template; apply by deleting the Pod (PodTemplateStale), no restart by the operator |
dns.staticRecords, dns.wildcardAddress, the hosts file | re-rendered live into the zone (and the extra-zone hosts key); no Pod change |
service.poolLabels | set/removed on the Service in place |
deletionPolicy, suspend | lifecycle only; a suspend/resume cycle keeps the identity |
tailnetRef, hostname, stateSecretRef, tags, replicas, service.loadBalancerIP, service.loadBalancerClass, dns.zone, dns.serviceName, dns.serviceClusterIP, dns.extraZones | identity — admission rejects most, the rest report IdentityChanged; recreate with deletionPolicy: Delete |
Replace a hand-built gateway
A cluster that already runs its own subnet router (a hand-written Tailscale StatefulSet with socat/CoreDNS sidecars, a VM, …) moves to the operator gateway without a tailnet outage for peers, because Headscale keeps a second, approved advertiser of the same prefixes as standby and fails over to it the moment the primary disappears. The operator never adopts the old router; the two run side by side until you delete the old one.
- Create the
TailnetGatewaynext to the old router with its ownhostname, its ownservice.loadBalancerIP, its owndns.serviceClusterIPanddeletionPolicy: Retain. Give it the sameroutes.lanandtailnetLBCIDR; advertising a prefix twice is intended. Wait forReady=True,RoutesInSync=True/Approved,DNSReady=True. - If the old router read the operator’s hosts file (
Tailnet.spec.dns.hostsConfigMap), keep that ConfigMap owned by the operator; the old sidecar may keep mounting it until step 5. - Flip the cluster resolver: point the kube-system CoreDNS forward for the tailnet zone (and
any corporate zones the old sidecar served) at the new
dns.serviceClusterIP(DNS with CoreDNS ). Compare a few lookups from a pod against the old resolver first; only names the old router answered from local state may differ. - Flip the tailnet resolver: set the Headscale split-DNS entry for the zone to the new
loadBalancerIPand grant tailnet peersudp:53/tcp:53to it. Verifydig @<loadBalancerIP> k8s.<zone>from a peer. - Retire the old router: delete its Headscale node (
headscale nodes delete) — routes fail over to the operator gateway immediately (headscale nodes list-routesshows it as the only advertiser) — then expire its pre-auth keys, remove its manifests and any Services and Secrets that held its state.
Anything that only the old router answered (API proxies on its tailnet IP, MagicDNS names such
as <old-hostname>) needs its own replacement before step 5: a TailnetIngress per API
(Ingress: TCP
) and the gateway’s dns.staticRecords for the names.
Extra zones for corporate resolvers
When peers must also resolve corporate zones whose resolvers carry
authoritative overrides (an SNI-routing proxy under proxy.corp.example, say),
add them as dns.extraZones. Names inside such a zone that appear in the Tailnet
hosts source are answered by the gateway; everything else is forwarded to the
listed upstreams. Add the zone to the Headscale split-DNS (nameservers.split)
pointing at the gateway LB IP, like the primary zone.
spec:
dns:
zone: prod-1.tailnet.example.internal
extraZones:
- zone: corp.example
upstreams: ["198.51.100.53", "198.51.100.54"]
- zone: partner.example
upstreams: ["198.51.100.53"]Overrides come from the hosts source (spec.dns.hostsConfigMap of the Tailnet, i.e.
TailnetEgress DNS aliases and TailnetIngress names): an alias such as
proxy.corp.example on an egress makes the gateway answer it with the egress
address instead of forwarding. Only A/AAAA are served locally; other record types for
an overridden name reach the upstream. Zones must not overlap each other or the
primary zone; dns.extraZones is immutable after creation.
Declared in the schema but rejected by the current writer (Ready=False/FreshBlocked or
admission): forwards[]/forwardImage (TCP forwards), authKeySecretRef
(external bootstrap key), adoption, IPv6 prefixes, replicas other than 1. Lifecycle, rollback
and deletion: gateway reference
.
Source: docs/how-to/gateway.md at v0.19.0 — found a mistake? Tell us.