Getting started
By the end of this tutorial a namespace in your cluster reaches a database at a remote site through a stable Service name, and a laptop on the tailnet reaches a database inside the cluster by DNS name. Every step shows the command and the output you should see. Budget: about thirty minutes, most of it waiting for pods.
Placeholders throughout: Tailnet corp, operator namespace tailnet-system, cluster prod-1,
tenant namespace billing-dev, team namespace shop-dev, site database 10.0.10.20:1521.
Replace them with your values as you go.
Before you start
You need:
- A Kubernetes cluster ≥ 1.31 with a CNI that enforces NetworkPolicy,
kubectlandhelm. - A Headscale 0.29.x server reachable from the cluster, and shell access to its CLI
(
headscale …; in-cluster:kubectl -n <ns> exec deploy/headscale -- headscale …). - A subnet router on the tailnet tagged
tag:fabricthat advertises the site network containing10.0.10.20(for example--advertise-routes=10.0.10.0/24), approved in Headscale. - Your own device enrolled in the tailnet, member of
group:shopin the policy.
The tutorial keeps the policy in file mode and edits it by hand, so you see exactly what the operator needs. Switching to an operator-owned policy comes afterwards (Let the operator own the policy ).
1. Create the bootstrap API key (2 min)
The operator authenticates with a long-lived bootstrap key it only reads; it mints and rotates its own working key from it.
headscale apikeys create --expiration 8760h
# <prints one key; copy it, you will not see it again>2. Add the policy entries (5 min)
Every egress and ingress node carries a tag, and the tag is the authorization. Add to your Headscale policy file:
{
"tagOwners": {
"tag:fabric": ["group:operator"],
"tag:egress-billing-dev": ["group:operator"],
"tag:ingress-shop": ["group:operator"]
},
"autoApprovers": { "routes": { "10.0.10.0/24": ["tag:fabric"] } },
"grants": [
{ "src": ["tag:egress-billing-dev"], "dst": ["10.0.10.20/32"], "ip": ["tcp:1521"] },
{ "src": ["group:shop"], "dst": ["tag:ingress-shop"], "ip": ["tcp:5432"] }
]
}Reload Headscale (SIGHUP or restart) and confirm it accepted the file:
headscale policy check -f /acl/policy
# Policy is valid3. Install the operator (5 min)
helm install tailnet-operator oci://oci.this-is-fine.io/charts/tailnet-operator \
--version 0.18.0 --namespace tailnet-system --create-namespace
kubectl -n tailnet-system create secret generic headscale-bootstrap --from-literal=apiKey='<key from step 1>'
kubectl -n tailnet-system rollout status deploy/tailnet-operator
# deployment "tailnet-operator" successfully rolled outUse your secret manager instead of --from-literal outside a tutorial.
4. Bind the Tailnet and pass preflight (3 min)
kubectl apply -f - <<'EOF'
apiVersion: tsunaro.io/v1alpha1
kind: Tailnet
metadata:
name: corp
spec:
server: https://ts.example.com
apiUrl: http://headscale.tailnet-system.svc:8080
apiSecretRef: { name: headscale-bootstrap, key: apiKey }
dns:
hostsConfigMap: tailnet-dns-hosts
ingressNameTemplate: "{name}-{namespace}.prod-1.tailnet.example.internal"
EOF
kubectl get tailnet corp
# NAME SERVER APIKEY EXPIRES COMPAT POLICY DNS AGE
# corp https://ts.example.com True 2026-11-14T12:00:00Z Incompatible CannotVerify 30sRead the three reason columns:
APIKEY=True— the operator reached the API and minted its working key (EXPIRESis that key).COMPAT=Incompatibleis expected in file mode: it means the operator cannot write the policy, which is what you want for now. In database mode it readsCompatible.DNS=CannotVerifyis always shown: Headscale has no DNS API, so the split-DNS zone (prod-1.tailnet.example.internal) is yours to configure later.
If APIKEY is not True, kubectl describe tailnet corp names the cause (ApiReachable,
ApiKeyValid); fix apiUrl or the Secret and the Tailnet retries on its own.
5. First egress (5 min)
- Advertise the target on the subnet router and approve it (or rely on
autoApprovers). - Declare tag, route approval and grant in the policy (step 2 ) — skip when the operator owns the policy.
- Apply the CR in the operator namespace (egress CRs are admin resources):
apiVersion: tsunaro.io/v1alpha1
kind: TailnetEgress
metadata:
name: billing-dev
namespace: tailnet-system
spec:
tailnetRef: { name: corp }
consumerNamespaces: [billing-dev]
targets:
- { name: db-1, address: 10.0.10.20, port: 1521 }kubectl -n tailnet-system get tailnetegress billing-dev
# NAME TAG HOSTNAME READY AGE
# billing-dev tag:egress-billing-dev egress-billing-dev True 2mA pod in billing-dev now dials db-1.tailnet-system.svc:1521. If READY is False, the
per-target reason in status.targets[] names the missing step — see
Reach a database at the site
.
Prove it from the consumer namespace, and prove the isolation from any other namespace:
kubectl create namespace billing-dev 2>/dev/null
kubectl -n billing-dev run t --rm -it --image=busybox --restart=Never -- nc -zw3 db-1.tailnet-system.svc 1521
# db-1.tailnet-system.svc (10.96.78.33:1521) open
kubectl -n default run t --rm -it --image=busybox --restart=Never -- nc -zw3 db-1.tailnet-system.svc 1521
# nc: timed out ← correct: the NetworkPolicy allows consumerNamespaces only6. First ingress (5 min)
Publish a database inside the cluster to the members of group:shop. The CR lives in the
tenant namespace — the team owns it:
kubectl apply -f - <<'EOF'
apiVersion: tsunaro.io/v1alpha1
kind: TailnetIngress
metadata:
name: shop
namespace: shop-dev
spec:
tailnetRef: { name: corp }
allowFrom: [group:shop]
exposes:
- { name: postgres, port: 5432, backend: { service: shop-db-rw, port: 5432 } }
EOF
kubectl -n shop-dev get tailnetingress shop
# NAME TAG HOSTNAME HOSTS READY AGE
# shop tag:ingress-shop ingress-shop [shop-shop-dev.prod-1.tailnet.example.internal] True 2mFrom your laptop (a member of group:shop):
psql -h ingress-shop -p 5432 -U app shop # MagicDNS name, works right awayThe longer name in HOSTS resolves once your resolver serves the operator’s hosts file —
that wiring is DNS with CoreDNS
, not part of this tutorial.
READY=False with BackendsReachable=False means shop-db-rw:5432 does not answer from the
agent pod (Service name, port, or a NetworkPolicy in shop-dev); the table in
Ingress: publish a TCP service
covers the other reasons.
What you have
- A per-namespace egress identity with its own tag, a stable in-cluster Service and a NetworkPolicy that keeps other namespaces out.
- A per-app ingress identity that tailnet peers reach by name, authorized by the policy, not by anything inside the cluster.
kubectl getoutput that names the missing step when something is not ready.
Where to go next
- Stop editing the policy by hand: Let the operator own the policy renders tags, approvals and grants from the CRs and writes them to Headscale.
- Serve the DNS names: DNS with CoreDNS , or run the subnet router and resolver as a CR with Run the gateway .
- Publish a web app with TLS on the agent: Ingress: publish a web app over HTTPS .
- Before production: Install the operator (resources, network isolation, what the site must provide) and the security model .
Source: docs/tutorials/getting-started.md at v0.18.0 — found a mistake? Tell us.