Tailscale Operator¶
Overview¶
The Tailscale Kubernetes operator runs in the tailscale namespace and acts as a bridge between Kubernetes resources and your Tailscale network. It watches for Services and Ingresses that request Tailscale exposure and provisions proxy StatefulSets that join the tailnet on their behalf.
In this cluster it is deployed via Flux CD using the official Helm chart:
| Item | Value |
|---|---|
| Chart | tailscale-operator |
| Chart repo | https://pkgs.tailscale.com/helmcharts |
| Operator hostname (tailnet) | k3s-tailscale-operator |
| Namespace | tailscale |
| Flux HelmRelease | tailscale-operator (tailscale.yaml) |
Prerequisites: Tailscale ACL Tags¶
The operator uses OAuth credentials scoped to specific ACL tags. These tags must exist in your Tailscale ACL policy before installing the operator. In the your-tailnet tailnet the following tags are defined:
| Tag | Purpose |
|---|---|
tag:k8s-operator |
The operator pod itself |
tag:k8s |
Proxy pods (owned by tag:k8s-operator) |
tag:k8s-operator-proxy |
Alternative proxy tag if needed |
Relevant ACL snippet (for reference):
"tagOwners": {
"tag:k8s-operator": [],
"tag:k8s": ["tag:k8s-operator"],
"tag:k8s-operator-proxy": ["tag:k8s-operator"]
}
OAuth Credentials¶
Why credentials are managed manually¶
The operator-oauth Secret in the tailscale namespace contains the OAuth client_id and client_secret that the operator uses to authenticate with Tailscale. These values must never be committed to git.
The git repo contains a placeholder secret (k3s/manifests/tailscale/operator-oauth-secret.yaml) with REPLACE_ME values so that Flux can create the Secret object. The secret carries the annotation kustomize.toolkit.fluxcd.io/reconcile: disabled so Flux will never overwrite real credentials with the placeholder:
# operator-oauth-secret.yaml (excerpt)
metadata:
annotations:
kustomize.toolkit.fluxcd.io/reconcile: disabled
One-time setup: creating the OAuth client¶
- Go to https://login.tailscale.com/admin/settings/oauth.
- Create a new OAuth client with:
- Scopes:
devices:write,dns:read,dns:write - Tags:
tag:k8s-operator - Copy the
client_idandclient_secret- the secret is shown once only.
Applying credentials to the cluster¶
Use kubectl patch (not kubectl apply) because the Secret was created via Flux's Server-Side Apply and a regular apply would conflict:
# Base64-encode your values first
CLIENT_ID_B64=$(echo -n "<your-client-id>" | base64)
CLIENT_SECRET_B64=$(echo -n "<your-client-secret>" | base64)
kubectl patch secret operator-oauth -n tailscale --type='json' -p="[
{\"op\":\"replace\",\"path\":\"/data/client_id\",\"value\":\"${CLIENT_ID_B64}\"},
{\"op\":\"replace\",\"path\":\"/data/client_secret\",\"value\":\"${CLIENT_SECRET_B64}\"}
]"
After patching, restart the operator so it picks up the new credentials:
Verify the operator joined the tailnet by checking the Tailscale admin console - you should see a device named k3s-tailscale-operator.
ProxyClass "prod"¶
The prod ProxyClass (defined in k3s/manifests/tailscale/proxyclass-default.yaml) configures all proxy pods to advertise the tag:k8s-operator tag:
apiVersion: tailscale.com/v1alpha1
kind: ProxyClass
metadata:
name: prod
spec:
statefulSet:
pod:
tailscaleInitContainer:
env:
- name: TS_EXTRA_ARGS
value: "--advertise-tags=tag:k8s-operator"
Reference this ProxyClass on any Service or Ingress that should use it:
Using prod ensures proxy devices appear with the correct ACL tag in the Tailscale admin console and inherit the right ACL permissions.
How Proxy Naming Works¶
When the operator provisions a proxy for a resource it creates a StatefulSet in the tailscale namespace named:
For example, an Ingress named myapp in namespace myapp produces a pod like:
The device appears in the Tailscale admin console with the hostname you specified (e.g., myapp), not the StatefulSet name.
List proxy pods:
Three Methods to Expose a Service¶
Method 1: Annotate a ClusterIP Service¶
The operator watches for the tailscale.com/expose: "true" annotation and creates a proxy StatefulSet. The service becomes reachable at <hostname>.daggertooth-scala.ts.net.
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: my-namespace
annotations:
tailscale.com/expose: "true"
tailscale.com/hostname: "my-service" # tailnet hostname (defaults to service name)
tailscale.com/proxy-class: "prod" # use the prod ProxyClass
spec:
type: ClusterIP
selector:
app: my-app
ports:
- port: 8080
targetPort: 8080
Use this method when you want raw TCP/UDP access or the service does not speak HTTP.
Method 2: LoadBalancer with Tailscale class¶
Set type: LoadBalancer and loadBalancerClass: tailscale. The operator fulfils the LoadBalancer allocation by assigning a Tailscale IP instead of a bare-metal IP.
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: my-namespace
annotations:
tailscale.com/hostname: "my-service"
tailscale.com/proxy-class: "prod"
spec:
type: LoadBalancer
loadBalancerClass: tailscale
selector:
app: my-app
ports:
- port: 80
targetPort: 8080
The EXTERNAL-IP field in kubectl get svc will show a Tailscale IP once the proxy is ready.
Method 3: Tailscale Ingress (HTTP/HTTPS - recommended for web services)¶
Create a standard Kubernetes Ingress with ingressClassName: tailscale. The operator provisions an HTTPS-terminating proxy. The service becomes reachable at https://<hostname>.daggertooth-scala.ts.net with a valid TLS certificate provisioned automatically by Tailscale.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: my-namespace
annotations:
tailscale.com/proxy-class: "prod"
spec:
ingressClassName: tailscale
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
tls:
- hosts:
- my-app # becomes my-app.daggertooth-scala.ts.net
Important - HTTP redirect gotcha: The Tailscale proxy sends traffic to your backend over plain HTTP (to the port listed in
backend.service.port). If your backend automatically redirectshttp://→https://you will get a redirect loop. Fix this by either:
- Disabling the internal HTTP→HTTPS redirect in your app (preferred), or
- Pointing the Ingress
portat the app's HTTPS port and accepting a self-signed cert.
Method 4: Tailscale Funnel (public internet exposure)¶
Tailscale Funnel exposes a service to the public internet - anyone can reach it, not just tailnet members. Traffic still routes through Tailscale's infrastructure, so there is no need to open firewall ports or configure port-forwarding on your router.
Important: Funnel is only available for HTTPS (port 443). The URL seen by the public is
https://<hostname>.daggertooth-scala.ts.net.
Prerequisites¶
The Tailscale ACL must grant the funnel attribute to the tag:k8s tag. This is already applied in opentofu/tailscale.tf:
Without this ACL entry the proxy pod will start but the tailscale funnel command will be rejected with a permission error.
How to enable Funnel on an Ingress¶
Add tailscale.com/funnel: "true" to the Ingress and reference the funnel ProxyClass (defined in k3s/manifests/tailscale/proxyclass-funnel.yaml):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-public-app
namespace: my-namespace
annotations:
tailscale.com/proxy-class: "funnel"
tailscale.com/funnel: "true"
spec:
ingressClassName: tailscale
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
tls:
- hosts:
- my-public-app # becomes my-public-app.daggertooth-scala.ts.net (public)
How to enable Funnel on a Service¶
Add the same annotation to a LoadBalancer service:
apiVersion: v1
kind: Service
metadata:
name: my-public-service
namespace: my-namespace
annotations:
tailscale.com/hostname: "my-public-service"
tailscale.com/proxy-class: "funnel"
tailscale.com/funnel: "true"
spec:
type: LoadBalancer
loadBalancerClass: tailscale
selector:
app: my-app
ports:
- port: 443
targetPort: 8080
Verifying Funnel is active¶
# Confirm the proxy pod joined the tailnet and funnel is serving
kubectl logs -n tailscale ts-my-public-app-<hash>-0 | grep -i funnel
# Expected: "funnel: serving on https://my-public-app.daggertooth-scala.ts.net"
# Test public access (from any device, even off the tailnet)
curl https://my-public-app.daggertooth-scala.ts.net
Example: Complete Walkthrough of Adding a New HTTP Service¶
This walks through exposing a hypothetical app my-dashboard running on port 3000.
Step 1 - Manifest files¶
k3s/manifests/my-dashboard/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-dashboard
namespace: my-dashboard
spec:
replicas: 1
selector:
matchLabels:
app: my-dashboard
template:
metadata:
labels:
app: my-dashboard
spec:
containers:
- name: my-dashboard
image: my-org/my-dashboard:latest
ports:
- containerPort: 3000
k3s/manifests/my-dashboard/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-dashboard
namespace: my-dashboard
spec:
type: ClusterIP
selector:
app: my-dashboard
ports:
- port: 3000
targetPort: 3000
k3s/manifests/my-dashboard/tailscale-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-dashboard
namespace: my-dashboard
annotations:
tailscale.com/proxy-class: "prod"
spec:
ingressClassName: tailscale
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-dashboard
port:
number: 3000
tls:
- hosts:
- my-dashboard
Step 2 - Register with Flux¶
k3s/flux/apps/my-dashboard.yaml:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: my-dashboard
namespace: flux-system
spec:
interval: 10m
path: ./k3s/manifests/my-dashboard
prune: true
sourceRef:
kind: GitRepository
name: homelab
targetNamespace: my-dashboard
Step 3 - Push and verify¶
git add k3s/manifests/my-dashboard/ k3s/flux/apps/my-dashboard.yaml
git commit -m "feat: add my-dashboard via Tailscale Ingress"
git push origin main
Flux reconciles within ~10 minutes (or run flux reconcile kustomization my-dashboard -n flux-system). Then:
# Watch for the proxy pod to appear
kubectl get pods -n tailscale -w
# Check the Ingress got an address
kubectl get ingress -n my-dashboard
Once the proxy is Running, the service is available at https://my-dashboard.daggertooth-scala.ts.net.