agentsclimarketplace

Tailscale k8s

Skill bmaltais/skills/tailscale-k8s

Run Tailscale inside a Kubernetes pod reliably — persistent identity, SSH host keys, RBAC, stop/start without re-registration, and API-based device cleanup. Use when: configuring tailscaled in a pod or StatefulSet, debugging Tailscale device getting a -1 suffix on pod restart, setting up Tailscale SSH in a container, storing Tailscale state in a K8s secret, managing Tailscale devices via API from a CLI or script, or asking why SSH fingerprints change after pod restart. Trigger on phrases like 'tailscale in kubernetes', 'tailscale pod keeps getting -1', 'tailscale ssh in container', 'TS_KUBE_SECRET', 'tailscale state kube', 'tailscale device cleanup', 'ssh host key changes on pod restart'.From its SKILL.md

Install
npx -y skills add bmaltais/skills --skill tailscale-k8s

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

9.8 KB, ~2.5k tokens by cl100k_base, as published. Nobody here has run it

Tailscale in Kubernetes

Patterns for running tailscaled inside a Kubernetes pod with persistent identity, stable SSH host keys, and reliable device lifecycle management.


Quick-Reference: Required Setup

ConcernSolution
Node identity persists across pod restart--state=kube:<secret-name>
SSH host keys persist (no fingerprint churn)--statedir=<pvc-backed-path>
Tailscale can write its K8s secretServiceAccount with get/create/update/patch on secrets
No -1 suffix on pod restartIdempotent tailscale up — skip authkey if already authenticated
Clean device removalScale pod to 0, wait for termination, then call API
stop preserves identity; delete removes from tailnetNever call API on stop — only on delete

tailscaled Startup Command

# Create dirs — statedir on the PVC so SSH host keys survive pod restarts
mkdir -p /var/run/tailscale
mkdir -p "/home/${USER}/.tailscale-state"
chown root:root "/home/${USER}/.tailscale-state"
chmod 700 "/home/${USER}/.tailscale-state"

tailscaled \
  --tun=userspace-networking \        # no /dev/net/tun needed in most k8s setups
  --state=kube:${TS_KUBE_SECRET} \   # auth state → K8s secret (persists across restarts)
  --statedir="/home/${USER}/.tailscale-state" \  # SSH host keys → PVC (persists)
  --socket=/var/run/tailscale/tailscaled.sock \
  &
TAILSCALED_PID=$!

# Wait for socket
for i in $(seq 1 30); do
  [ -S /var/run/tailscale/tailscaled.sock ] && break
  sleep 1
done

Why both --state and --statedir?

  • --state=kube:<name> stores the Tailscale node key and auth credentials in a K8s Secret. Without this, the pod re-registers as a brand new device on every restart and gets a -1, -2, ... suffix.
  • --statedir stores SSH host keys (and other local files). If this points to an emptyDir, the SSH host keys are regenerated on every restart, causing WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED for every client. Point it to a PVC-backed path.

Without --statedir, tailscaled logs:

warning: unable to get SSH host keys, SSH will appear as disabled for this node: no var root for ssh keys

Idempotent tailscale up

Never unconditionally pass --authkey on every pod start. If the pod already has valid state in the K8s secret, passing an authkey registers a new device instead of resuming, resulting in a -1 suffix.

# Check if already authenticated before deciding whether to pass authkey
if tailscale status --peers=false &>/dev/null; then
  # Resuming — auth state loaded from K8s secret
  tailscale up \
    --hostname="${TS_HOSTNAME}" \
    --ssh \
    --accept-routes
else
  # First boot — register with authkey
  tailscale up \
    --authkey="${TS_AUTHKEY}" \
    --hostname="${TS_HOSTNAME}" \
    --ssh \
    --accept-routes
fi

Required RBAC

tailscaled with --state=kube: needs to read and write a K8s Secret in its own namespace. Without this it crashes or falls back to file state.

// ServiceAccount
&corev1.ServiceAccount{
    ObjectMeta: metav1.ObjectMeta{Name: "myapp-tailscale", Namespace: ns},
}

// Role
&rbacv1.Role{
    ObjectMeta: metav1.ObjectMeta{Name: "myapp-tailscale", Namespace: ns},
    Rules: []rbacv1.PolicyRule{
        {
            APIGroups: []string{""},
            Resources: []string{"secrets"},
            Verbs:     []string{"get", "create", "update", "patch"},
        },
        {
            APIGroups: []string{""},
            Resources: []string{"events"},
            Verbs:     []string{"get", "create", "patch"},
        },
    },
}

// RoleBinding — bind the Role to the ServiceAccount

Set spec.serviceAccountName on the PodSpec to the ServiceAccount name.

Required env vars on the container:

- name: TS_KUBE_SECRET
  value: "tailscale-state-<instance-name>"   # unique per pod/StatefulSet
- name: TS_AUTHKEY
  valueFrom:
    secretKeyRef:
      name: myapp-ts-<instance-name>
      key: authkey
- name: TS_HOSTNAME
  value: "myapp-<instance-name>"

Volume Setup

Only one emptyDir is needed — for the Tailscale socket. SSH host keys and auth state both go to persistent storage:

# StatefulSet volumes
volumes:
- name: tailscale-run
  emptyDir: {}           # /var/run/tailscale — socket only, safe as emptyDir

# No tailscale-state emptyDir needed — statedir goes on the home PVC

volumeMounts:
- name: tailscale-run
  mountPath: /var/run/tailscale
- name: home                       # PVC
  mountPath: /home/<user>
  # ~/.tailscale-state/ lives here — created by init script

Device Lifecycle: Stop vs Delete

Stop (scale to 0, resume later)

Do not call the Tailscale API on stop. The device stays registered as offline. When the pod restarts:

  • Auth state is loaded from the K8s secret → same node key → same device
  • SSH host keys are loaded from the PVC → same fingerprint
  • The pod rejoins the tailnet as the same device, no -1 suffix
# Correct stop sequence
kubectl scale statefulset myapp-<name> --replicas=0 -n <ns>
# Wait for pod to terminate
# DO NOT call DELETE /api/v2/device/{id}

Delete (permanent removal)

Must call the API after the pod is fully terminated. If you call it while tailscaled is still running, it immediately re-registers with the same hostname — the deletion has no effect.

# Correct delete sequence
kubectl scale statefulset myapp-<name> --replicas=0 -n <ns>
# Wait for pod to fully terminate — THEN call API
curl -s -u "${TS_API_KEY}:" \
  -X DELETE "https://api.tailscale.com/api/v2/device/${DEVICE_ID}"
# Now safe to delete the StatefulSet, Service, Secrets, etc.

Go implementation

// WRONG — calling API on stop breaks identity on resume
func runStop(...) {
    ScaleStatefulSet(ctx, cs, ns, name, 0)
    WaitForStopped(ctx, cs, ns, name)
    tsapi.DeleteDevice(apiKey, hostname)  // ← removes device; next start gets -1 suffix
}

// CORRECT — stop just scales down
func runStop(...) {
    ScaleStatefulSet(ctx, cs, ns, name, 0)
    WaitForStopped(ctx, cs, ns, name)
    // identity preserved in K8s secret + PVC
}

// CORRECT — delete: stop first, then API, then k8s cleanup
func runDelete(...) {
    ScaleStatefulSet(ctx, cs, ns, name, 0)
    WaitForStopped(ctx, cs, ns, name)   // MUST wait — pod gone before API call
    tsapi.DeleteDevice(apiKey, hostname)
    DeleteStatefulSet(...)
    DeleteSecrets(...)
}

Tailscale API — Find and Delete Device by Hostname

// GET /api/v2/tailnet/-/devices — find by hostname prefix, then DELETE
func DeleteDevice(apiKey, hostname string) (bool, error) {
    req, _ := http.NewRequest("GET",
        "https://api.tailscale.com/api/v2/tailnet/-/devices?fields=all", nil)
    req.SetBasicAuth(apiKey, "")

    resp, err := http.DefaultClient.Do(req)
    // ... parse JSON, find device where d.Hostname == hostname or HasPrefix
    // DELETE https://api.tailscale.com/api/v2/device/{id}
    // Returns (true, nil) on success, (false, nil) if not found, (false, err) on error
}

Use a prefix match (strings.HasPrefix(d.Hostname, hostname)) rather than exact match — Tailscale may register the device as myapp-pi or myapp-pi-<suffix> depending on history.


Userspace Networking Notes

--tun=userspace-networking avoids the need for /dev/net/tun and NET_ADMIN capability, which makes it safe for unprivileged pods. Tradeoff: the pod cannot establish direct WireGuard connections and always relays through Tailscale DERP servers. This adds ~15–30ms latency vs direct connections.

If low latency matters (e.g. VS Code Remote SSH), use kernel-mode Tailscale with:

securityContext:
  capabilities:
    add: ["NET_ADMIN"]
# and mount /dev/net/tun as a hostPath or use a device plugin

Tailscale SSH ACL

To allow SSH without interactive browser auth prompts, add to your tailnet ACL:

"ssh": [
  {
    "action": "accept",
    "src":    ["autogroup:member"],
    "dst":    ["tag:devpod"],           // or autogroup:self for personal tailnets
    "users":  ["autogroup:nonroot", "root"]
  }
]

Common Pitfalls

PitfallFix
Device gets -1 suffix on pod restartUsing file-based --state on emptyDir — use --state=kube:<secret>
SSH disabled: no var root for ssh keysMissing --statedir or --statedir on emptyDir — point to PVC path
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGEDSSH host keys on emptyDir — move --statedir to PVC
API delete has no effect (device re-registers instantly)Pod still running — scale to 0 and wait for termination first
Stop breaks Tailscale identity on resumeCalling DELETE /api/v2/device/{id} on stop — only call on delete
Always registers as new device even with kube statePassing --authkey unconditionally — check tailscale status first
tailscaled CrashLoopBackOff: missing permissionsNo RBAC for TS_KUBE_SECRET — add ServiceAccount/Role/RoleBinding
DERP relay, high latency (15–30ms)Userspace networking can't do direct WireGuard — use kernel mode if latency matters

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,835. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.