Skip to content
Open source Apache-2.0 · cosign-signed · SBOM on every release

Model the access, 
derive the network.

Connect Kubernetes workloads to the systems they need — on your own Headscale tailnet. 
Declare access in YAML; Tsunaro manages the connection and reports reachability.

From a workload to a remote system

From your cluster to services at your site.

Your application uses an in-cluster Service name. Tsunaro manages the egress agent that connects it to the remote service through your tailnet.

  1. Your clusterKubernetes workloadAllowed consumer namespace
  2. Managed by TsunaroService + egress agentDeclared with TailnetEgress
  3. Your siteRemote serviceReached through a subnet router
The encrypted tailnet connection runs between the egress agent and your subnet router. Headscale coordinates identities and policy; it does not carry application traffic.

Bring your own Headscale. Policy management is opt-in; custom-resource writers must be trusted. Read the security model before deployment.

A guided tour

Declare it once, the operator does the rest

Start with a manifest for your scenario. Tsunaro manages the connection or routes and reports conditions with reasons you can act on. Enable policy management to generate tags, approvals and grants; otherwise manage them yourself.

5 scenarios — choose one to explore. Scroll the scenario list sideways for more.

Reach a database at your site

A namespace needs an Oracle database and a broker that live on a LAN. One TailnetEgress names the targets; consumers dial them as in-cluster Services.

 1---
 2# One egress identity in the operator's namespace; consumerNamespaces
 3# decides who may reach its Services. The tag needs tagOwners, a /32
 4# route approval and a grant per target: with Tailnet.spec.policy the
 5# operator generates them, otherwise an admin writes them and the
 6# operator only reports what is missing. Each target becomes a Service
 7# named after it: <target>.<operator-namespace>:<port>.
 8apiVersion: tsunaro.io/v1alpha1
 9kind: TailnetEgress
10metadata:
11  name: billing-dev
12  namespace: tailnet-system
13spec:
14  tailnetRef:
15    name: corp
16  # tag: tag:ns-billing-dev      # default: tag:egress-{name}
17  # hostname: egress-billing-dev # default: egress-{name}
18  consumerNamespaces:
19    - billing-dev
20  targets:
21    - name: oracle-db-1
22      address: 10.0.10.20
23      port: 1521
24    - name: oracle-db-2
25      address: 10.0.10.21
26      port: 1521
27    - name: broker-1
28      address: 10.0.20.10
29      port: 5672
30    # A target routed by name (TLS SNI / HTTP Host) behind a reverse
31    # proxy at the far site: consumers must dial the real hostname, so
32    # publish it as an alias of the Service ClusterIP via
33    # Tailnet.spec.dns.hostsConfigMap. The L4 tunnel passes SNI through.
34    - name: api-corp
35      address: 10.0.30.10
36      port: 443
37      aliases:
38        - api.corp.example
Follow the guide: Egress: reach services at the site

Publish a service on the tailnet

A team exposes its Postgres and Redis as one userspace node with its own MagicDNS name. allowFrom is the policy grant — who may dial it.

 1---
 2# One ingress identity in the tenant namespace: a single userspace node
 3# (MagicDNS name ingress-shop) exposing in-cluster Services on the
 4# tailnet. Each expose is a plain-TCP listener (L4, any protocol)
 5# forwarded to a Service in this namespace. Who may reach it is a
 6# policy grant with dst = the tag: with Tailnet.spec.policy the operator
 7# generates it from allowFrom (each entry must match the admin's
 8# ingressSourceAllowlist, else PolicyDenied); otherwise an admin writes
 9# tag and grant and the operator only reports what is missing.
10# A tailnet peer then dials e.g. ingress-shop:5432.
11apiVersion: tsunaro.io/v1alpha1
12kind: TailnetIngress
13metadata:
14  name: shop
15  namespace: shop-dev
16spec:
17  tailnetRef:
18    name: corp
19  # tag: tag:ingress-shop      # default: tag:ingress-{name}
20  # hostname: ingress-shop     # default: ingress-{name}
21  allowFrom: [group:shop]      # grant src (operator-owned policy only)
22  exposes:
23    - name: postgres
24      port: 5432               # port the tailnet node listens on
25      backend:
26        service: shop-db-rw    # Service in this namespace
27        port: 5432
28    - name: redis
29      port: 6379
30      backend:
31        service: shop-redis
32        port: 6379
33    # HTTPS funnel: TLS still terminates at the shared Envoy gateway
34    # (wildcard cert, HTTPRoute by Host), but the tailnet identity — and
35    # so the grant — is this app's own node. Needs
36    # Tailnet.spec.dns.ingressNameTemplate; status.dnsName resolves to
37    # this node and is the hostname the tenant puts on its HTTPRoute.
38    # - name: https
39    #   port: 443
40    #   backend:
41    #     service: envoy-prod-1-tailnet
42    #     namespace: envoy-gateway-system   # cross-namespace backend
43    #     port: 443
Follow the guide: Ingress: publish a TCP service

Own the policy, review before Apply

The base document lives in Git; the operator appends tagOwners, route approvals and grants from the CRs. Start in DryRun, read the rendered output, then switch to Apply.

 1---
 2# A control server the cluster talks to. Cluster-scoped: one per
 3# Headscale, shared by every CR. The bootstrap API key is read-only; the
 4# operator mints and rotates its own working key from it into
 5# <name>-apikey, which is not managed by GitOps.
 6apiVersion: tsunaro.io/v1alpha1
 7kind: Tailnet
 8metadata:
 9  name: corp
10spec:
11  server: https://ts.example.com
12  # In-cluster REST endpoint if it differs from the public login URL.
13  apiUrl: http://headscale.tailnet-system.svc:8080
14  apiSecretRef:
15    name: headscale-bootstrap
16    key: apiKey
17  # apiKeyLifetime: 720h   # working key, rotated at half-life
18  # authKeyLifetime: 168h  # per-CR pre-auth key, rotated at half-life
19  proxy:
20    # Optional registry Secret names; must exist in EVERY agent
21    # namespace. Omit to inherit AGENT_IMAGE_PULL_SECRETS from the
22    # operator; [] clears that default.
23    # imagePullSecrets: [dockerconfig]
24    mtu: 1200
25    probeInterval: 5m
26  # DNS names (optional). The operator renders a CoreDNS `hosts` file
27  # into this ConfigMap (operator namespace, key "hosts"): egress target
28  # aliases -> Service ClusterIP, ingress dnsNames -> node tailnet IPs.
29  # Mount it into a resolver authoritative for the zones
30  # (docs/how-to/dns-coredns.md). Keep the ingress name ONE label below
31  # the gateway's wildcard cert, e.g. *.prod-1.tailnet.example.internal.
32  dns:
33    hostsConfigMap: tailnet-dns-hosts
34    ingressNameTemplate: "{name}-{namespace}.prod-1.tailnet.example.internal"
35    # Optional short alias for HTTP exposes with `alias: true`. Must end
36    # in the SAME zone as ingressNameTemplate.
37    # ingressAliasTemplate: "{name}.prod-1.tailnet.example.internal"
38  # Operator-owned policy (optional; needs Headscale policy mode
39  # "database", docs/how-to/policy.md). The base document
40  # (policy-base.jsonc) lives in Git -> ConfigMap; the operator appends
41  # tagOwners, /32 route approvals and grants derived from the CRs and
42  # writes the union. Start in DryRun, read <baseConfigMap>-rendered,
43  # then switch to Apply.
44  policy:
45    baseConfigMap: headscale-policy
46    mode: DryRun
47    tagOwners: [group:operator]
48    routeApprovers: [tag:fabric]
49    ingressSourceAllowlist: ["group:*"]
50  # Address space no TailnetSite may claim (optional): the node prefix
51  # of this control server and, per cluster, LAN/pod/service/tailnet-LB
52  # ranges.
53  reservedNetworks:
54    - name: nodes
55      prefixes: [100.64.0.0/16]
56    - name: prod-1
57      prefixes: [192.0.2.0/24, 10.244.0.0/16, 10.96.0.0/12, 198.51.100.0/24]
Follow the guide: Let the operator own the policy

Bring a whole branch network in

A subnet router advertises a branch /24 and a management host. The operator approves the routes, replays them on every advertiser and reports conflicts with your reserved ranges before they cause an outage.

 1---
 2# A remote network a tagged subnet router advertises into the tailnet:
 3# a branch (/24), a management range or a single host (/32).
 4# Cluster-scoped, in the cluster whose operator owns the policy (mode
 5# Apply). The operator appends autoApprovers for the prefixes, replays
 6# the approval on every node carrying an advertiser tag — in this or
 7# another cluster — and reports
 8# Advertised/Approved/Ready. Who may reach the prefixes stays a grant in
 9# the base policy. Overlap with Tailnet.spec.reservedNetworks or an
10# older TailnetSite blocks the CR (RouteConflict) until resolved.
11# docs/how-to/site-network.md: a branch behind a tagged router you run and
12# the prod-1 LAN behind the operator-managed gateway
13# (deploy/examples/tailnetgateway.yaml) are declared the same way.
14apiVersion: tsunaro.io/v1alpha1
15kind: TailnetSite
16metadata:
17  name: branch-north
18spec:
19  tailnetRef:
20    name: corp
21  prefixes:
22    - 203.0.113.0/24
23    - 198.18.0.17/32
24  # Routers allowed to advertise these prefixes. Omit to use
25  # Tailnet.spec.policy.routeApprovers (the gateway's tag).
26  advertisedBy:
27    tags: [tag:fabric]
28  owner: netops
29  description: Branch office north, reached through the on-prem subnet router
Follow the guide: Declare a site network (TailnetSite)

Run the gateway as a CR

A subnet router for the cluster LAN and the tailnet load-balancer range, plus a split-DNS zone — declared once, suspended until the prerequisites are in place.

 1---
 2# The gateway of docs/how-to/gateway.md: subnet router for the prod-1
 3# LAN and the tailnet LB range, plus the split-DNS zone served from the
 4# operator's hosts file. Shipped with suspend: true so the fixture
 5# admits without creating anything; set suspend: false once the
 6# prerequisites are in place (kernel, LB pool matching poolLabels,
 7# policy Apply, Headscale split-DNS). The zone is prod.example.internal
 8# here (the envtest DNS fixtures pin it); the how-to uses
 9# prod-1.tailnet.example.internal.
10apiVersion: tsunaro.io/v1alpha1
11kind: TailnetGateway
12metadata:
13  name: router
14spec:
15  suspend: true
16  deletionPolicy: Retain
17  tailnetRef:
18    name: corp
19  hostname: prod-router
20  tags: [tag:fabric]
21  stateSecretRef:
22    name: router-state
23  # Version tag or digest; prefer a digest in production.
24  image: ghcr.io/tailscale/tailscale:v1.102.4
25  routes:
26    lan: [192.0.2.0/24]
27    advertiseEgressTargets: true
28  service:
29    loadBalancerIP: 198.51.100.17
30    tailnetLBCIDR: 198.51.100.0/24
31    poolLabels:
32      io.cilium/lb-pool: tailnet
33  dns:
34    zone: prod.example.internal
35    serviceName: router-dns
36    serviceClusterIP: 10.96.0.54
37    image: docker.io/coredns/coredns:1.14.7
38    staticRecords:
39      - name: api
40        target: kube-api-platform.prod.example.internal
41      - name: router
42        address: 192.0.2.1
43    wildcardAddress: 198.51.100.80
Follow the guide: Run the gateway (subnet router + split-DNS)

Start with a pinned release

These commands install the operator and wait for its Deployment. They do not configure Headscale or grant network access.

  • Kubernetes 1.31 or later, a CNI that enforces NetworkPolicy, Helm and kubectl.
  • A reachable Headscale 0.29.x server. The tutorial also needs a subnet router, a reachable site database and an enrolled client device.

Need a control server first? Prepare Headscale . Before deploying, verify the release signatures .

Install the operator · v0.18.0
helm install tailnet-operator oci://oci.this-is-fine.io/charts/tailnet-operator \
  --version 0.18.0 \
  --namespace tailnet-system --create-namespace

kubectl --namespace tailnet-system rollout status deployment/tailnet-operator

Next: configure access. The step-by-step tutorial covers the bootstrap API key, its Kubernetes Secret, policy entries and complete Tailnet, egress and ingress manifests. Replace its example addresses and names with your own.

For production settings, see installation and prerequisites . The examples and commands on this site target v0.18.0.

SBOM downloads · v0.18.0

Software bills of materials (SBOMs) inventory the software in each platform image. They are not vulnerability reports or compliance certificates.

linux/amd64

SPDX-2.3 · 253.8 KiB

Download SPDX JSON Signed Sigstore bundle · 270.2 KiB
Verify offline and extract

Download the bundle and public key into one directory. With Cosign 3, jq and base64 installed, these commands verify the signed embedded SBOM, not the separately downloaded JSON. After successful verification, they extract the authenticated SBOM to a file prefixed with verified-.

cosign verify-blob-attestation \
  --key cosign.pub \
  --insecure-ignore-tlog \
  --type spdxjson \
  --digestAlg sha256 \
  --digest 46ee66545446b4886498f625a931fc9f4a0dac6ecb61f6caed76bd0a438353d4 \
  --bundle linux-amd64.sigstore.json &&
# Extract the authenticated SBOM only after verification succeeds.
jq -r '.dsseEnvelope.payload' linux-amd64.sigstore.json \
  | base64 --decode \
  | jq '.predicate' > verified-linux-amd64.spdx.json

linux/arm64

SPDX-2.3 · 253.8 KiB

Download SPDX JSON Signed Sigstore bundle · 270.2 KiB
Verify offline and extract

Download the bundle and public key into one directory. With Cosign 3, jq and base64 installed, these commands verify the signed embedded SBOM, not the separately downloaded JSON. After successful verification, they extract the authenticated SBOM to a file prefixed with verified-.

cosign verify-blob-attestation \
  --key cosign.pub \
  --insecure-ignore-tlog \
  --type spdxjson \
  --digestAlg sha256 \
  --digest ffd8df293413ea62fae76a7b3ea03fe188aee3f618278e2f5eb8b2e4e1c55de0 \
  --bundle linux-arm64.sigstore.json &&
# Extract the authenticated SBOM only after verification succeeds.
jq -r '.dsseEnvelope.payload' linux-arm64.sigstore.json \
  | base64 --decode \
  | jq '.predicate' > verified-linux-arm64.spdx.json
What verification establishes

Cross-check cosign.pub against the key in the release source through a trusted channel before verification. A key downloaded beside a bundle is not an independent trust anchor.

Checksums detect corruption, not authenticity. Offline verification checks the signature and platform image digest; it does not check transparency-log inclusion.

Explore the documentation