I run a local kind cluster for my homelab on a mini PC, and I wanted to reach its API server from my laptop over Tailscale instead of only from the machine kind runs on.

On my main cluster, the Tailscale Kubernetes operator runs under Flux. For in-cluster services, I set the Ingress class to Tailscale and the operator exposes them on the tailnet automatically.

kind runs in Docker, so the API server port is mapped to localhost on the host not 0.0.0.0. We can confirm with:

docker ps --filter name=control-plane --format "{{.Names}}\t{{.Ports}}"
kind-control-plane   127.0.0.1:41993->6443/tcp

Nothing outside that host can reach it over the tailnet. Even if it could, the API server’s TLS cert only lists localhost, 127.0.0.1, the container’s Docker IP, and the kind-control-plane hostname. Connect with a Tailscale IP or MagicDNS name and the cert check fails—the name was never on the list.

We could skip verification with --insecure-skip-tls-verify, but that defeats the point of TLS.

Get the hostname into the cert

kind sets up its control plane using kubeadm under the hood, even though we never type kubeadm init ourselves. It’s baked into the node image. So the normal kubeadm cert workflow still applies here.

kubeadm keeps the API server’s intended SAN list in the kubeadm-config ConfigMap in kube-system, at ClusterConfiguration.apiServer.certSANs—not only in the certificate file on disk.

Patch the ConfigMap first. If we skip that step, a later cert renewal can drop our Tailscale names even after we regenerate the cert once.

kubectl get configmap kubeadm-config -n kube-system -o jsonpath='{.data.ClusterConfiguration}'
apiServer:
  certSANs:
  - localhost
  - 127.0.0.1
  - 100.x.x.x
  - kind-host.tail8f3a2b.ts.net

Patching the ConfigMap is not enough on its own. kubeadm certs renew does not apply the updated SAN list—I learned that when I tried:

docker exec kind-control-plane kubeadm certs renew apiserver

It runs fine and says the cert was renewed, but all it actually does is extend the expiry date.

It just reuses the SAN list that’s already baked into the current cert, ignoring whatever we put in the ConfigMap. The new names never show up.

What we actually need is to regenerate the cert from scratch with kubeadm init phase certs apiserver. That command reads a full ClusterConfiguration (plus an InitConfiguration for advertiseAddress) and rebuilds the SAN list from there.

Back up the existing apiserver cert and key first:

docker exec kind-control-plane cp /etc/kubernetes/pki/apiserver.crt /etc/kubernetes/pki/apiserver.crt.bak
docker exec kind-control-plane cp /etc/kubernetes/pki/apiserver.key /etc/kubernetes/pki/apiserver.key.bak

Remove the old cert files so kubeadm regenerates instead of refusing.

docker exec kind-control-plane rm -f /etc/kubernetes/pki/apiserver.crt /etc/kubernetes/pki/apiserver.key

Then run:

docker exec kind-control-plane kubeadm init phase certs apiserver --config /kubeadm-cert-regen.yaml
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kind-control-plane kubernetes
kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost
kind-host.tail8f3a2b.ts.net] and IPs [10.96.0.1 172.19.0.4 127.0.0.1 100.x.x.x]

The --config file must include two YAML documents.

InitConfiguration sets the node’s real advertiseAddress. ClusterConfiguration should match the live ConfigMap from kube-system, with our Tailscale SANs added—copy the rest as is so clusterName, serviceSubnet, and podSubnet do not drift.

apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 172.19.0.4
  bindPort: 6443
nodeRegistration:
  name: kind-control-plane
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
apiServer:
  certSANs: [localhost, 127.0.0.1, 100.x.x.x, kind-host.tail8f3a2b.ts.net]
clusterName: kind
controlPlaneEndpoint: kind-control-plane:6443
networking:
  serviceSubnet: 10.96.0.0/16
  podSubnet: 10.244.0.0/16
kubernetesVersion: v1.35.1

Then restart the static Pod so it actually picks up the new files.

The kube-apiserver static Pod doesn’t watch its cert files for changes, so the process that’s already running is still holding onto the old cert in memory. Just stopping its container is enough—the kubelet brings static Pods back on its own.

CID=$(docker exec kind-control-plane crictl ps --name kube-apiserver -q)
docker exec kind-control-plane crictl stop "$CID"

A few seconds later kubectl get --raw /healthz comes back ok, and the new cert is live.

echo | openssl s_client -connect 127.0.0.1:41993 -servername kind-host.tail8f3a2b.ts.net 2>/dev/null \
  | openssl x509 -noout -text | grep -A3 "Subject Alternative Name"
X509v3 Subject Alternative Name:
    DNS:kind-control-plane, DNS:kubernetes, ..., DNS:kind-host.tail8f3a2b.ts.net,
    IP Address:10.96.0.1, IP Address:172.19.0.4, IP Address:127.0.0.1, IP Address:100.x.x.x

Forward the API port with tailscale serve

With the cert sorted, the port is still only listening on 127.0.0.1.

Tailscale on the mini PC runs at the node level—the host is already on our tailnet so we do not need to touch kind’s Docker port mapping or recreate the cluster.

tailscale serve on the host can forward a tailnet-facing port straight to the loopback address where kind mapped the API server.

tailscale serve --bg --tcp 6443 tcp://127.0.0.1:41993
tailscale serve status
|-- tcp://kind-host.tail8f3a2b.ts.net:6443 (tailnet only)
|-- tcp://100.x.x.x:6443
|--> tcp://127.0.0.1:41993

To verify it works:

curl --cacert ca.crt https://kind-host.tail8f3a2b.ts.net:6443/healthz
ok

No --insecure-skip-tls-verify needed.

On the laptop side, the kubeconfig just needs its server: field pointed at the MagicDNS name. Same CA and client cert as before, since neither of those changed.

clusters:
- cluster:
    certificate-authority-data: <unchanged>
    server: https://kind-host.tail8f3a2b.ts.net:6443
  name: kind-kind

Once I had it working, I wondered why nothing new showed up in the Tailscale admin console. A newly registered node usually appears there, but tailscale serve is not that kind of change it runs on the node itself, not as something the control plane tracks tailnet wide.

We are not adding a device or publishing a subnet route that needs approval. We are opening extra TCP listeners on a host that is already on the tailnet.

The console is still the place for devices, routes, ACL tags, and Funnel, the path that exposes services to the public internet. Plain serve without --funnel stays on the tailnet and does not get its own row in the UI.

Run tailscale serve status on the host when we want to see what is listening.

What happens when the mini PC reboots? The tailscale serve forward usually comes back on its own once tailscaled starts. The --bg flag stores that config in tailscaled’s state, not in our shell session.

Notes

  • The SAN list lives in the kubeadm-config ConfigMap (ClusterConfiguration.apiServer.certSANs). Patch the ConfigMap first so later renewals keep our Tailscale names.
  • kubeadm certs renew only extends expiry. It won’t pick up new SANs. Regenerate with kubeadm init phase certs apiserver --config ... and a full InitConfiguration + ClusterConfiguration.
  • The kube-apiserver static Pod does not reload certs from disk. Stop its container and let the kubelet bring it back.
  • tailscale serve --bg forwards a tailnet port to the loopback mapping without touching kind’s Docker networking. After a reboot, tailscaled usually restores the forward on its own.