The Tailscale Kubernetes Operator is how household phones reach ClusterIP Services that should not sit on Cloudflare. It is installed only by Argo CD Application tailscale, which pins official Helm chart tailscale-operator at 1.102.3 (app v1.102.3) from https://pkgs.tailscale.com/helmcharts into namespace tailscale. Do not helm install it from a workstation, and do not run a node agent or a homegrown subnet-router Deployment. Patch bumps within this pin are a new change; a chart other than 1.102.3 needs a deliberate re-pin.
v1 is the operator plus Home Assistant on the tailnet. Home Assistant stays ClusterIP on port 8123. The phone path is MagicDNS after an expose annotation on that Service, not a Cloudflare Public Hostname, not MetalLB, and not a Connector that advertises cluster CIDRs. Site values live in homelabs-private/clusters/<cluster>/. Public git keeps placeholders only: no OAuth client strings, no tskey- auth keys, no live *.ts.net names.
Role in the stack#
flowchart LR
subgraph git["Git"]
App[Application tailscale]
ES[ExternalSecret]
end
subgraph cluster["Cluster"]
V[Vault KV]
Sec[Secret operator-oauth]
Op[Operator]
HA[HA ClusterIP :8123]
end
subgraph tailnet["Tailnet"]
Phone[Phone]
end
V --> ES
ES --> Sec
Sec --> Op
App --> Op
Phone -->|MagicDNS :8123| HA
The operator joins the household tailnet as a tagged machine and can expose selected ClusterIP Services to that tailnet. Jellyfin and Navidrome stay on the Cloudflare Tunnel. Pi-hole and Minecraft stay LAN LoadBalancers. Home Assistant is the first, and in v1 the only, consumer.
There is no Longhorn claim. Health is the operator Deployment Ready. Prometheus does not scrape the operator in v1.
GitOps layout#
Application tailscale is helm-first: upstream chart, then git refs so Helm can load value files, then the private ExternalSecret. Sync-wave is -1 (with Kyverno; after External Secrets Operator at -2). CreateNamespace and ServerSideApply are on: Argo creates namespace tailscale if it is missing, and the chart emits large CRDs. Automated prune and selfHeal are on. Helm releaseName is tailscale-operator.
| Piece | Where |
|---|---|
| Application CR | homelabs-private/clusters/<cluster>/argocd/applications/tailscale.yaml |
| Public values | homelabs/tailscale/values.yaml |
| Private overlay | homelabs-private/clusters/<cluster>/overlays/tailscale/values.yaml |
| ExternalSecret | homelabs-private/clusters/<cluster>/manifests/tailscale/ |
Public values set installCRDs: true, drop all capabilities on the operator with allowPrivilegeEscalation: false, and request 100m CPU / 128Mi memory with limits 500m / 256Mi. They leave Helm oauth.clientId and oauth.clientSecret empty so the chart does not emit Secret operator-oauth. ESO owns that object. Do not set fullnameOverride; the chart names the Deployment operator and the OAuth Secret operator-oauth. Do not point an ExternalSecret at Secret operator (that name is operator node state).
The private overlay sets operatorConfig.hostname: tailscale-operator only, a generic machine name, not a live MagicDNS FQDN. It does not put OAuth strings in Helm values.
Do not put live hostnames, OAuth ids, or cluster CIDRs in homelabs/tailscale/.
AppProject#
Allow Helm repo https://pkgs.tailscale.com/helmcharts, destination namespace tailscale, and cluster kind IngressClass (the chart always emits one). Keep the whitelist additive. Do not add Connector as an allowed cluster kind in v1.
The homelab AppProject is applied outside app-of-apps. Committing the YAML is not enough. Apply the live project before you expect Argo to accept Application tailscale:
kubectl apply -f homelabs-private/argocd/projects/homelab.yamlWithout that destination and sourceRepo, sync fails because project homelab does not allow the chart or namespace.
OAuth from Vault#
The operator authenticates with an OAuth client, not a reusable auth key in git. Seed Vault before first-sync:
vault kv put secret/homelab/tailscale \
OAUTH_CLIENT_ID='<oauth-client-id>' \
OAUTH_CLIENT_SECRET='<oauth-client-secret>'Private ExternalSecret tailscale-operator-oauth in namespace tailscale maps those properties onto Secret operator-oauth keys client_id and client_secret (creationPolicy: Owner, deletionPolicy: Retain). The operator Deployment mounts that Secret. If Vault is sealed or the path is missing, the ExternalSecret stays SecretSyncedError and the operator cannot join the tailnet.
Tailnet console#
Git does not own tailnet policy. In the Tailscale admin console, before you rely on a Healthy Application:
- Set
tagOwnerssotag:k8s-operatorownstag:k8s. - Create an OAuth client whose write scopes match Tailscale’s current Kubernetes operator install doc. Put the client id and secret in Vault (command above). Never commit them.
- Allow household devices to reach the Home Assistant tailnet identity on port
8123. Keep Funnel off.
ACL text stays in the console, not in homelabs.
First sync#
Do this after public homelabs/tailscale/ is on main. Order matters: Vault and the AppProject before a successful Application sync.
- Finish the console tags and OAuth client, then seed Vault.
- Apply the AppProject (command above) so namespace
tailscaleand the Helm repo are allowed. - Commit the private Application, overlay, and ExternalSecret. App-of-apps discovers Application
tailscale. - Wait until the Application is Synced/Healthy. Confirm the operator Deployment is Ready and the ExternalSecret is SecretSynced. In the Tailscale admin console, confirm a machine named
tailscale-operator(or the hostname you set) has joined the tailnet.
kubectl -n argocd get application tailscale
kubectl -n tailscale get deploy,externalsecret,pods
kubectl -n tailscale get pvcThe PVC list in tailscale stays empty. Do not first-sync Home Assistant’s expose annotation in the same commit as this Application.
Home Assistant on the tailnet#
Both Home Assistant value layers keep pajikos service.type: ClusterIP and service.port: 8123. Git must not set type: LoadBalancer or loadBalancerClass: tailscale on that Service.
The private Home Assistant overlay in v1 GitOps still repeats ClusterIP only. The expose annotation is a later overlay commit, after an owner exists:
service:
type: ClusterIP
port: 8123
annotations:
tailscale.com/expose: "true"Create the owner on a workstation port-forward before that annotation syncs. See Home Assistant. After the annotation is live, a phone on the tailnet opens http://homeassistant.<tailnet>.ts.net:8123. That hostname is a placeholder; the operator assigns the real MagicDNS name. Do not advertise the cluster service CIDR, and do not dial the ClusterIP from the phone.
Do not add a Cloudflare Public Hostname for Home Assistant in v1. If you later want a browser URL on the existing tunnel, Cloudflare Access stays on. Access-off is still rejected.
Rollback#
Disable or delete Application tailscale. Home Assistant ClusterIP remains. Revoke the OAuth client in the Tailscale admin console. Remove Vault keys after the namespace is gone if you want them gone. Leave Jellyfin, Navidrome, and cloudflared alone.
See also#
- Home Assistant: ClusterIP
:8123, port-forward owner, later expose - Argo CD: AppProject, app-of-apps, sync
- External Secrets Operator: Vault → Secret
operator-oauth - HashiCorp Vault: KV seed before first-sync
- Kyverno: same wave
-1and ServerSideApply pattern