A cluster you cannot reach from outside is not broken — it is private by construction: no LoadBalancers, no ingress, an API server on the internal interface only. For years the problem lived in the last mile: getting the team and CI in without opening a door to the internet. The Tailscale Kubernetes Operator closes it natively — services publish straight into the tailnet, and a Connector CRD subnet-routes the whole cluster CIDR, so a plain Grafana ClusterIP becomes an address in your mesh.
Why now: the operator docs and the Connector CRD — subnet router and exit node in one resource — were thoroughly updated in June 2026, deploying a subnet router became the officially recommended path into a cluster, and ProxyGroup plus the API server proxy closed the remaining gaps: HA and kubectl without a public API.
Table of contents
Open Table of contents
A cluster without public IPs: what replaces ingress
What a “private cluster” means in practice: on managed offerings — private node groups and a control plane with a VPC-only endpoint; on your own VMs — nodes without external addresses, a kube-apiserver on the internal interface. Inside, everything works: pods talk, ClusterIPs get handed out, a CI runner in the same network deploys without questions. The problem starts when a developer opens a laptop at home.
The usual ways in have long settled, and each has a price. A VPN server or bastion next to the cluster — one more machine to patch and monitor, and a single point of failure. Ingress with an office-IP allowlist — nearly free, but office addresses rot the moment the ISP changes, and the LB :443 still sticks out. Cloudflare Tunnel — we wrote about it: an outbound tunnel with no open ports, but every service lives behind a public hostname on someone else’s network. NetBird — covered too: a self-hosted WireGuard mesh with policies, but you still bring the cluster in by hand, as a separate container.
All variants share one thing: something public remains, and that something gets scanned by bots around the clock.
A zero attack surface is not “a very well-protected door” — it is no door at all: nothing to scan, nothing to brute-force, nothing to catch a CVE. A cluster with private node groups and not a single LoadBalancer gives you exactly that — as long as everyone who needs it fits inside the tailnet.
How the operator opens the cluster into the tailnet
The operator comes as a helm chart with its CRDs. Network-wise it does three things; a private cluster needs the first two.
Per service. Give a Service loadBalancerClass: tailscale (or the classic tailscale.com/expose: "true" annotation with tailscale.com/tailnet-hostname: grafana) — the operator spins up a proxy pod, and the tailnet gains a dedicated device: MagicDNS name grafana.<tailnet>.ts.net, real HTTPS certificate. An Ingress with ingressClassName: tailscale does the same. Surgical mode: one ACL rule per service.
The whole cluster at once. A Connector resource runs a subnet router over the CIDRs you pick — service and pod. One manifest, and every ClusterIP is reachable from the tailnet without touching the services themselves. replicas: 2 adds failover; the same resource can be an exit node.
kubectl without a public API. If you install with apiServerProxyConfig.mode="true", tailscale configure kubeconfig appends a tailnet context to your kubeconfig: kubectl talks to the API server directly, and RBAC works off your Tailscale identity — the proxy threads it into request headers.
There is a reverse direction too: Egress services (ExternalName with the tailscale.com/tailnet-fqdn annotation) let pods reach tailnet resources, and a ProxyClass with exitNodeIP routes proxy egress through an office exit node — handy for legacy systems tied to the office IP.
Two more things worth knowing. Funnel: the tailscale.com/funnel: "true" annotation publishes that same ingress publicly — through Tailscale, with TLS, no LoadBalancer in the cluster; the one legitimate door for “show the customer a demo”, closed by deleting the annotation. ProxyGroup: proxy pods behind one MagicDNS name, for per-service HA and multi-cluster setups — replicas share the name, dead ones drop out automatically.
Comparison in one table
| VPN server | NetBird | Cloudflare Tunnel | Tailscale operator | |
|---|---|---|---|---|
| Public endpoints | a VPN port exposed | none | no ports, but hostnames are public | none at all |
| Control plane | your server | your self-host or their cloud | Cloudflare | Tailscale (managed) |
| Price | a VM + your time | OSS, free | generous free plan | free up to 3 users |
| Control plane down | VPN dead — everyone offline | new peers can’t join | no Cloudflare — no access | live tunnels keep working, new ones don’t |
| What the team gets | the whole network as one segment | mesh + policies | web services by hostname | ClusterIPs, pods, kubectl, MagicDNS |
The key difference: NetBird and Cloudflare Tunnel answer “how do I reach the cluster”; the operator answers “how do I hand out the whole cluster” — pod network and API server included, no separate VM, no public hostname even for authentication.
What you need to set up private access
Preconditions: a cluster with known CIDRs (service and pod — check the kube-apiserver’s --service-cluster-ip-range flag and the CNI config), a tailnet with IdP groups (group:dev below), helm 3.
1. ACL: tags and route auto-approval. The operator won’t start without its tags, so the ACL comes first:
"tagOwners": {
"tag:k8s-operator": ["group:admin"],
"tag:k8s-router": ["tag:k8s-operator"]
},
"autoApprovers": {
"routes": {
"10.96.0.0/12": ["tag:k8s-router"],
"10.244.0.0/16": ["tag:k8s-router"]
}
}
autoApprovers lets subnet routes approve themselves — no manual click in Machines for every replica.
2. OAuth client. In the admin console: Trust credentials → + Credential. Scopes: Services (Read/Write), Devices — Core (Read/Write), Keys — Auth Keys (Read/Write); tag — tag:k8s-operator. Save the Client ID and Client Secret.
3. The operator. One command:
helm repo add tailscale https://charts.tailscale.com
helm install tailscale-operator tailscale/tailscale-operator \
-n tailscale --create-namespace \
--set oauth.clientId=<CLIENT_ID> \
--set oauth.clientSecret=<CLIENT_SECRET> \
--set-string apiServerProxyConfig.mode="true"
4. The Connector. The subnet router over the whole cluster:
apiVersion: tailscale.com/v1alpha1
kind: Connector
metadata:
name: k8s-router
spec:
hostnamePrefix: k8s-router
tags: ["tag:k8s-router"]
replicas: 2
subnetRouter:
advertiseRoutes:
- "10.96.0.0/12" # Service CIDR — ClusterIPs
- "10.244.0.0/16" # Pod CIDR
5. Access for the team. One grant for the dev group — no VPN server involved:
"grants": [{
"action": "accept",
"src": ["group:dev"],
"dst": ["10.96.0.0/12:*", "10.244.0.0/16:*"]
}]
Granularity is your call: different teams get different ranges and rules; single-service access is what the per-service mode with a tag per service is for.
How to verify
First the router itself and its routes:
kubectl get connector k8s-router
# NAME SUBNETROUTES ISEXITNODE STATUS
# k8s-router 10.96.0.0/12,10.244.0.0/16 false ConnectorCreated
tailscale status | grep k8s-router
tailscale ping k8s-router # a direct tunnel to the router
Then from a developer laptop — Grafana by ClusterIP, with zero public endpoints:
kubectl -n monitoring get svc grafana \
-o jsonpath='{.spec.clusterIP}{"\n"}' # say, 10.96.42.17
curl -I http://10.96.42.17:3000 # HTTP/1.1 200 OK
And kubectl over the tailnet:
tailscale configure kubeconfig
kubectl --context=<tailnet-context> get ns
The final check is the attacker’s view: from a machine outside the tailnet, nmap -Pn <node-ip> should show nothing but the node’s SSH — no :6443, no cluster :443.
Pitfalls
- The route sits unapproved. The Connector’s tag doesn’t match the one in
autoApprovers, or a CIDR is typo’d — the route waits for manual approval in Machines while you assume everything works. Always check the SUBNETROUTES column. - A grant wider than intended. Access to the whole service CIDR is access to every service in the cluster, production databases included. Draw internal boundaries with separate grants per range; for single services use the annotation mode.
- No names, only IPs. A subnet router gives you addresses, not cluster DNS:
grafana.monitoring.svc.cluster.localwon’t resolve from a laptop. If you need names, add the per-service mode with MagicDNS. - All traffic rides the router. Packets to ClusterIPs flow through the Connector pods — this is not P2P. Run
replicas: 2and set real resource requests; for heavy volumes the surgical mode wins — its proxy pods live next to the services. - Overlapping subnets. If the tailnet already contains 10.96.0.0/… from the office network, IPv4 routes conflict — Tailscale has 4via6 routes for exactly this, addressing subnets into an IPv6 space.
Bottom line
For an internal product, a cluster without public IPs is not exotic — it is the shortest path to a zero attack surface: nothing to scan, nothing to catch a CVE, nothing to hide behind a WAF. The cost is a dependency on the tailnet and ACL discipline instead of allowlists; the payoff is kubectl and Grafana from any device on the team, a CI runner in the same network, and zero public hostnames. The operator and one Connector come up in an evening — and the next step, with the same operator, is closing egress too, through an office exit node.