The Tailscale Kubernetes Operator

Tailscale Kubernetes Operator

Make your Kubernetes networking life easier with the Tailscale Kubernetes operator.


tl;dr

Modern Kubernetes networking involves multiple complex layers: physical networks, CNI plugins like Calico (handling BGP meshes, VXLAN, IPIP tunnels, and NAT), service meshes, and external access concerns. While the post dives into Calico’s implementation details in a homelab setup—showing how it manages IP pools, BGP peering, and cross-node communication—the Tailscale Kubernetes operator emerges as a surprisingly straightforward solution for secure service exposure, requiring just a simple annotation to make workloads accessible across your entire tailnet.

Here’s a diagram of my homelab Kubernetes setup, including Tailscale.

Network Diagram

Click image to enlarge

Introduction

There are many layers to networking, especially when you add Kubernetes into the mix. In my relatively simple homelab setup, I have a physical network, a Kubernetes network based on Calico, and now I’m adding Tailscale. But there can be more layers, and more complexity.

Kubernetes networking is interesting because one of the early design decisions was that every pod should have its own routable layer 3 IP address. In some ways, this is a much simpler design for networking, just make everything layer 3 routable. Now we can run thousands and thousands of pods without having to worry about broadcast domains.

But in order to use Kubernetes, you have to set up a CNI–a container network interface. Kubernetes doesn’t actually do its own networking, it just uses the CNI plugin. And the CNI is, in many ways, free to implement the network in any way it wants, from straight Layer 3, to BGP meshes, to overlays like VXLAN and IPIP, or a combination of all of those and other things. So yes, each pod has its own IP address, but how that actually works is up to the CNI.

But there’s more!

  • More interestingly, we still need to “expose” workloads, whether via a load balancer, ingress or other means.
  • We have added the concept of service meshes.
  • And we know we have the concept of zero trust networking.
  • And what if we want to access services in other clusters?
  • Or services running in Kubernetes need to access external services?

I’m not stating anything new here, Kubernetes is getting quite mature, as has the networking capabilities that we layer on top. There are a lot of interesting options available.

The Tailscale Kubernetes Operator

Tailscale makes creating software-defined networks easy: securely connecting users, services, and devices. - https://tailscale.com

First, I’ve written about Tailscale before.

Tailscale Mullvad

Also check out my other post about using Tailscale with Kubernetes.

That post is more about building up a safe personal network combined with Mullvad VPN. This post is about securing and building connectivity with Tailscale’s Kubernetes operator.

Tailscale provides several options for setting up in Kubernetes, and one of them is the Tailscale Kubernetes Operator, which is what I’ll be using in this post.

But first, let’s get Kubernetes installed.

Installing Kubernetes

First, I’ll use my Kubernetes installer script, brilliantly named install-kubernetes, to set up a Kubernetes cluster. It simply installs a simple kubeadm-based cluster on Ubuntu 22.04 virtual machines, adding in Calico as the CNI.

root@ts1:~# git clone https://github.com/ccollicutt/install-kubernetes
Cloning into 'install-kubernetes'...
remote: Enumerating objects: 105, done.
remote: Counting objects: 100% (18/18), done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 105 (delta 12), reused 12 (delta 7), pack-reused 87 (from 1)
Receiving objects: 100% (105/105), 23.90 KiB | 479.00 KiB/s, done.
Resolving deltas: 100% (52/52), done.
root@ts1:~# cd install-kubernetes
root@ts1:~/install-kubernetes# ./install-kubernetes.sh -s
Starting install...
==> Logging all output to /tmp/install-kubernetes-R21z3PlZYv/install.log
Checking Linux distribution
Disabling swap
Removing packages
Installing required packages
Installing Kubernetes packages
Configuring system
Configuring crictl
Configuring kubelet
Configuring containerd
Installing containerd
Starting services
Configuring control plane node...
Initialising the Kubernetes cluster via Kubeadm
Configuring kubeconfig for root and ubuntu users
Installing Calico CNI
==> Installing Calico tigera-operator
==> Installing Calico custom-resources
Waiting for nodes to be ready...
==> Nodes are ready
Checking Kubernetes version...
==> Client version: v1.31.0
==> Server Version: v1.31.0
==> Requested KUBE_VERSION matches the server version.
Installing metrics server
Configuring as a single node cluster
Configuring as a single node cluster
Deploying test nginx pod
Waiting for all pods to be running...
Install complete!

### Command to add a worker node ###
kubeadm join 10.10.10.250:6443 --token <REDACTED> --discovery-token-ca-cert-hash <REDACTED> 

This gives me an initial Kubernetes node, which is both a control plane and a worker node. Next, I add another worker node, but I won’t show that output here.

So now I’ve got two nodes, ts1 and ts2.

$ k get nodes
NAME   STATUS   ROLES           AGE   VERSION
ts1    Ready    control-plane   15h   v1.31.0
ts2    Ready    <none>          15h   v1.31.0

Installing the Tailscale Kubernetes Operator

Setting up Tailscale

This includes the following steps (see the docs for more details):

  1. Setting up an OAuth client ID and secret
  2. Configuring tags in the ACLs

Kubernetes operator tailscale docs

  • Set up OAuth client ID and secret

Tailscale OAuth client ID and secret

  • Set up ACL tags

Tailscale ACL Tags

  • See the results in machines once the operator is installed and some workloads are created (note that this is after the operator is installed, and some workloads are created)

Machines

Install into Kubernetes with Helm

I’ll show you my justfile commands for installation.

add-tailscale-helm:
    helm repo add tailscale https://pkgs.tailscale.com/helmcharts
    helm repo update

install-tailscale-operator:
    helm upgrade \
        --install \
        tailscale-operator \
        tailscale/tailscale-operator \
        --namespace=tailscale \
        --create-namespace \
        --set-string oauth.clientId="${TS_CLIENT_ID}" \
        --set-string oauth.clientSecret="${TS_CLIENT_SECRET}" \
        --wait

Thanks to Helm, very simple to install.

$ just install-tailscale-operator

What Does This Network Look Like?

In this section we’ll go over the layers. To visualize those layers, here’s a diagram of my homelab setup.

Disclaimer: This is a basic diagram of my homelab setup and is not completely accurate. Please refer to official Tailscale and Calico and Kubernetes documentation!

Homelab networking diagram

Physical Networking

The physical networking is straightforward: the two nodes/vms are on the same VLAN, and have direct access to each other. (I use Incus to manage my VMs.)

Calico Networking

The Calico configuration is what you get by default. Calico is VERY configurable, and you can do a lot of complex things with it, but this is the default deployment.

$ kubectl get ippool -o yaml
apiVersion: v1
items:
- apiVersion: projectcalico.org/v3
  kind: IPPool
  metadata:
    creationTimestamp: "2025-01-31T23:51:56Z"
    name: default-ipv4-ippool
    resourceVersion: "891"
    uid: 15bc140e-701b-4cb9-aea8-82e74925b997
  spec:
    allowedUses:
    - Workload
    - Tunnel
    blockSize: 26
    cidr: 192.168.0.0/16
    ipipMode: Never
    natOutgoing: true
    nodeSelector: all()
    vxlanMode: CrossSubnet
kind: List
metadata:
  resourceVersion: ""

or…

# calicoctl get ippool default-ipv4-ippool -oyaml
apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
  creationTimestamp: "2025-01-31T23:51:56Z"
  name: default-ipv4-ippool
  resourceVersion: "891"
  uid: 15bc140e-701b-4cb9-aea8-82e74925b997
spec:
  allowedUses:
  - Workload
  - Tunnel
  blockSize: 26
  cidr: 192.168.0.0/16
  ipipMode: Never
  natOutgoing: true
  nodeSelector: all()
  vxlanMode: CrossSubnet

IP address management is handled by Calico.

# calicoctl ipam show --show-blocks
+----------+--------------------+-----------+------------+--------------+
| GROUPING |        CIDR        | IPS TOTAL | IPS IN USE |   IPS FREE   |
+----------+--------------------+-----------+------------+--------------+
| IP Pool  | 192.168.0.0/16     |     65536 | 15 (0%)    | 65521 (100%) |
| Block    | 192.168.131.64/26  |        64 | 8 (12%)    | 56 (88%)     |
| Block    | 192.168.153.128/26 |        64 | 7 (11%)    | 57 (89%)     |
+----------+--------------------+-----------+------------+--------------+

Default settings:

  • VXLAN mode is set to CrossSubnet, meaning that Calico will only use VXLAN encapsulation when pods are communicating across different subnets.
  • IPIP mode is set to Never, which means that IPIP tunneling is not used.
  • The network CIDR is 192.168.0.0/16 with a block size of 26, giving 62 usable IPs in each block as managed by Calico.
  • natOutgoing: true means that outgoing traffic is NATed to the external IP address of the node.

So…

  • Nodes communicate BGP routes over their physical network (10.10.x.x)
  • Nodes get IPs from the configured pool (192.168.0.0/16)
  • The physical network handles the actual packet delivery between the nodes
  • There is NAT involved (multiple NATs actually)
# calicoctl ipam show
+----------+----------------+-----------+------------+--------------+
| GROUPING |      CIDR      | IPS TOTAL | IPS IN USE |   IPS FREE   |
+----------+----------------+-----------+------------+--------------+
| IP Pool  | 192.168.0.0/16 |     65536 | 15 (0%)    | 65521 (100%) |
+----------+----------------+-----------+------------+--------------+
# calicoctl node status
Calico process is running.

IPv4 BGP status
+--------------+-------------------+-------+----------+-------------+
| PEER ADDRESS |     PEER TYPE     | STATE |  SINCE   |    INFO     |
+--------------+-------------------+-------+----------+-------------+
| 10.10.10.246 | node-to-node mesh | up    | 23:55:06 | Established |
+--------------+-------------------+-------+----------+-------------+

IPv6 BGP status
No IPv6 peers found.

There are only two nodes, so the BGP mesh is, well, as small as it gets.

We can see all the pods and such and what hosts they are on, IPs, etc.

$ just show-pod-details 
POD NAME                    POD IP          NODE NAME        NODE IP
-------------------------  --------------  ---------------  --------------
calico-apiserver-64497c8b94-2xlqq          192.168.131.72    ts1    10.10.10.250
calico-apiserver-64497c8b94-c7px8          192.168.131.71    ts1    10.10.10.250
calico-kube-controllers-7d868b8f66-85ldw   192.168.131.67    ts1    10.10.10.250
calico-node-b68ck                          10.10.10.246      ts2    10.10.10.246
calico-node-j5sw5                          10.10.10.250      ts1    10.10.10.250
calico-typha-764c8bcd98-pwnf4              10.10.10.250      ts1    10.10.10.250
csi-node-driver-2bg5q                      192.168.131.70    ts1    10.10.10.250
csi-node-driver-6hs69                      192.168.153.129   ts2    10.10.10.246
hello-tailscale-expose-74c4485894-xms5d    192.168.153.137   ts2    10.10.10.246
hello-tailscale-5c8bf6665c-7lqvj           192.168.153.131   ts2    10.10.10.246
coredns-6f6b679f8f-knnlg                   192.168.131.65    ts1    10.10.10.250
coredns-6f6b679f8f-sdrp6                   192.168.131.68    ts1    10.10.10.250
etcd-ts1                                   10.10.10.250      ts1    10.10.10.250
kube-apiserver-ts1                         10.10.10.250      ts1    10.10.10.250
kube-controller-manager-ts1                10.10.10.250      ts1    10.10.10.250
kube-proxy-gm8rh                           10.10.10.246      ts2    10.10.10.246
kube-proxy-lq5p9                           10.10.10.250      ts1    10.10.10.250
kube-scheduler-ts1                         10.10.10.250      ts1    10.10.10.250
metrics-server-5f94f4d4fd-rr92x            192.168.131.64    ts1    10.10.10.250
operator-6999975fd7-dxvv5                  192.168.153.130   ts2    10.10.10.246
ts-hello-tailscale-expose-db59d-0          192.168.153.136   ts2    10.10.10.246
ts-hello-tailscale-z5dfr-0                 192.168.153.133   ts2    10.10.10.246
tigera-operator-b974bcbbb-hrzv9            10.10.10.250      ts1    10.10.10.250

Tailscale Networking

Tailscale uses Wireguard to create a secure network.

Tailscale is built on top of WireGuard; we think very highly of it - https://tailscale.com/compare/wireguard

Tailscale takes Wireguard quite a bit further, dealing with all the other issues around modern networks, e.g. NAT (which can be brutal to deal with), and adding Access Control Lists (ACLs) and other features.

Tailscale takes care of on-demand NAT traversal so that devices can talk to each other directly in most circumstances, without manual configuration. When NAT traversal fails, Tailscale relays encrypted traffic, so that devices can always talk to each other, albeit with higher latency in that case. There is no need to modify firewalls or routers; any devices that can reach the internet can reach each other. (Tailscale traffic between two devices on the same LAN does not leave that LAN.) - https://tailscale.com/compare/wireguard

In the Kubernetes deployment we have a proxy pod that is setup for each exposed service by the operator–at least that is the way that I understand it.

$ k get pods -n tailscale 
NAME                                READY   STATUS    RESTARTS   AGE
operator-6999975fd7-dxvv5           1/1     Running   0          140m
ts-hello-tailscale-expose-db59d-0   1/1     Running   0          80m
ts-hello-tailscale-z5dfr-0          1/1     Running   0          119m

The ts-hellow-tailscale* workloads are running in their own namespaces, above are the tailscale pods for those workloads.

Accessing Workloads With Tailscale

Here’s some justfile commands to curl the hello-tailscale service.

Note that this workload is using the Tailscale annotation to expose the service, but you can also use the Tailscale LoadBalancer service type, and a couple of other options I believe.

deploy-tailscale-hello-with-expose:
    #!/usr/bin/env bash
    set -euxo pipefail
    kubectl apply -f - <<'EOF'
    apiVersion: v1
    kind: Namespace
    metadata:
      name: hello-tailscale-expose
    ---
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: nginx-index-html
      namespace: hello-tailscale-expose
    data:
      index.html: |
        <h1>Hello from Tailscale Expose!</h1>
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: hello-tailscale-expose
      namespace: hello-tailscale-expose
      labels:
        app: hello-tailscale-expose
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: hello-tailscale-expose
      template:
        metadata:
          labels:
            app: hello-tailscale-expose
        spec:
          containers:
            - name: hello-tailscale-expose
              image: nginx:latest
              ports:
                - containerPort: 80
              volumeMounts:
                - name: nginx-index
                  mountPath: /usr/share/nginx/html
          volumes:
            - name: nginx-index
              configMap:
                name: nginx-index-html
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: hello-tailscale-expose
      namespace: hello-tailscale-expose
      annotations:
        tailscale.com/expose: "true"
    spec:
      selector:
        app: hello-tailscale-expose
      ports:
        - port: 80
          targetPort: 80
    EOF

And we can easily access that service from my workstation, which is on the tailnet!

📝 Note that one of these workloads I specified a loadbalancer for, and for the other I used the Tailscale expose annotation.

$ tailscale status | grep hello
100.80.241.127  hello-tailscale-expose-hello-tailscale-expose tagged-devices linux   -
100.85.85.53    hello-tailscale-hello-tailscale tagged-devices linux   idle, tx 532 rx 316
100.101.102.103 hello.ts.net         hello@       linux   -
$ curl hello-tailscale-hello-tailscale
<h1>Hello from Tailscale LoadBalancer!</h1>
$ curl hello-tailscale-expose-hello-tailscale-expose
<h1>Hello from Tailscale Expose!</h1>

And we can see that in this screenshot as well.

Hello from Tailscale LoadBalancer

Conclusion

Tailscale solves a lot of problems for me, especially when it comes to using containers. Networking has become very complex. For example, in the past in my homelab I’d set up MetalLB to create a load balancer for applications. MetalLB is an amazing piece of technology (thank you to those that built and maintain it!), but I’d be managing IPs and trying to remember which ranges I’d allowed the loadbalancer to use, and inevitably I’d forget. Never mind that those workloads wouldn’t be accessible from my laptop–when I was at home on my workstation, sure, no problem, but when I was on the road I’d have Tailnet but no Kubernetes access. Now I have that too, plus the ability to use ACLs to control access to my Kubernetes cluster. Or, potentially, I can deploy apps that I will use, and be able to access them from my phone too. Very nice! Now I can deploy apps into Kubernetes and use them from any of my devices.

📝 Note that there are some things to think about when using Kubernetes, NAT, and Tailscale. Best to read this Tailscale blog post for more details.

Further Reading

PS. Mermaid Diagram Code

Again, not a perfect diagram, but it’s a good start.

graph TB
    subgraph Disclaimer[**This is a basic diagram of my homelab setup and may contain inaccuracies.<br>Please refer to official Tailscale documentation.**]
        style Disclaimer fill:#fff,stroke:#f66,stroke-width:2px,stroke-dasharray: 5 5
    end

    subgraph Physical Network
        DHCP[DHCP Server]
        VLAN[VLAN Network]
        DHCP --> VLAN
    end

    subgraph Kubernetes Cluster
        subgraph Kubernetes Nodes
            Node1[Node ts1<br>Control Plane + Worker]
            Node2[Node ts2<br>Worker]
            VLAN --> Node1
            VLAN --> Node2
        end

        subgraph Calico Networking
            BGPBGP Mesh<br>(Node to Node)
            IPPOOL[IP Pool<br>natOutgoing: true]
            Node1 <--> BGP
            Node2 <--> BGP
            BGP --> IPPOOL
            Pod1[Pods on ts1]
            Pod2[Pods on ts2]
            IPPOOL --> Pod1
            IPPOOL --> Pod2
            Pod1 -.->|NAT| INTERNET
            Pod2 -.->|NAT| INTERNET
        end

        subgraph Example Service
            HELLO[hello-tailscale-expose]
            TS_HELLO[ts-hello-tailscale-expose<br>Tailscale Proxy Pod]
            HELLO --> TS_HELLO
        end

        Pod1 --> TS_OP
        Pod2 --> TS_OP

        subgraph Tailscale Layer
            TS_OP[Tailscale Operator<br>Pod]
            TS_OP --> TS_HELLO
        end
    end

    subgraph Tailnet Devices
        LAPTOP[Laptop<br>Tailscale Client]
    end

    TS_CTRL[Tailscale Control Plane<br>Cloud Service]
    INTERNET((Internet))
    
    %% Encrypted connections as dotted lines
    TS_CTRL -.- TS_OP
    TS_CTRL -.- LAPTOP
    LAPTOP -.- TS_HELLO
    
    classDef physical fill:#f9f,stroke:#333,stroke-width:2px
    classDef calico fill:#bbf,stroke:#333,stroke-width:2px
    classDef tailscale fill:#bfb,stroke:#333,stroke-width:2px
    classDef k8s fill:#fff,stroke:#326CE5,stroke-width:2px
    classDef device fill:#fdb,stroke:#333,stroke-width:2px
    classDef service fill:#dfd,stroke:#333,stroke-width:2px
    
    class DHCP,VLAN physical
    class BGP,IPPOOL,Pod1,Pod2 calico
    class TS_CTRL,TS_OP tailscale
    class Kubernetes_Cluster k8s
    class LAPTOP device
    class HELLO,TS_HELLO service