Skip to content
HoginHogin
Go back

Tailscale Kubernetes Operator: a private cluster without public IPs — ClusterIP straight into your tailnet

8 мин чтения

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.

Attack surface: public doors vs a cluster reachable only from the tailnet

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.

Two modes: one service at a time with a MagicDNS name, or a Connector router over the whole CIDR

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 serverNetBirdCloudflare TunnelTailscale operator
Public endpointsa VPN port exposednoneno ports, but hostnames are publicnone at all
Control planeyour serveryour self-host or their cloudCloudflareTailscale (managed)
Pricea VM + your timeOSS, freegenerous free planfree up to 3 users
Control plane downVPN dead — everyone offlinenew peers can’t joinno Cloudflare — no accesslive tunnels keep working, new ones don’t
What the team getsthe whole network as one segmentmesh + policiesweb services by hostnameClusterIPs, 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

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.


Share this post:

Next Post
Argo Rollouts + Gateway API: canary with auto-rollback, no service mesh required