Headscale & Tailscale

Tailscale creates a secure WireGuard mesh between all your machines, phones, and servers. For full privacy and control, Headscale provides an open-source, self-hosted implementation of the Tailscale coordination server.

Why Headscale?

  • Zero Open Ports: Nodes establish direct WireGuard tunnels using DERP relays and NAT traversal (STUN/ICE), working cleanly behind CGNAT.
  • Full Ownership: Control user namespaces, pre-auth keys, ACLs, and node registrations on your own VPS.
  • Custom MagicDNS: Use custom domain names and internal IP addresses (such as 100.64.0.0/10) for your homelab services.

Kubernetes Ingress via Tailscale / Headscale

Instead of exposing services directly to the public internet or managing complex VPN gateways, you can run a dedicated Tailscale ingress proxy pod inside your cluster. The proxy joins your mesh and forwards all received mesh traffic directly to your cluster's ingress controller (such as Traefik or ingress-nginx).

[ Tailscale / Headscale Client ]
               |
               v (Tailnet WireGuard Traffic)
   [ tailscale-ingress Pod ]
               | (Kernel Forwarding via TS_DEST_IP)
               v
     [ Traefik Service IP ]
               |
      +--------+--------+
      |                 |
[ IngressRoute / HTTPRoute / Ingress ]
      |                 |
  [ App A ]         [ App B ]

Architecture & Key Settings

  1. State Persistence in Secrets (TS_KUBE_SECRET): The official Tailscale container automatically persists its node state and WireGuard private key inside a Kubernetes Secret. This ensures the node retains its IP address and registration across pod restarts.
  2. One-Time Authentication (TS_AUTH_ONCE=true): Uses an auth key only on first registration; subsequent boots rely on the saved secret.
  3. Transparent IP Forwarding (TS_DEST_IP): In kernel-networking mode, setting TS_DEST_IP instructs the container to forward all incoming connections to the target IP (the cluster IP of Traefik) without port translation.
  4. Recreate Strategy: Setting strategy: type: Recreate ensures that two pods never attempt to use the same Tailscale machine identity at the same time during updates.

Example Kubernetes Manifests

1. RBAC (ServiceAccount & Secret Permissions)

The proxy pod requires permission to create and read its state secret:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tailscale-ingress
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: tailscale-ingress
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create"]
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["tailscale-ingress-state"]
    verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: tailscale-ingress
  namespace: default
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: tailscale-ingress
subjects:
  - kind: ServiceAccount
    name: tailscale-ingress
    namespace: default

2. Auth Secret

apiVersion: v1
kind: Secret
metadata:
  name: tailscale-ingress-auth
  namespace: default
type: Opaque
stringData:
  TS_AUTHKEY: "tskey-auth-xxxxxx" # Pre-auth key from Tailscale or Headscale

3. Ingress Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: tailscale-ingress
  namespace: default
  labels:
    app: tailscale-ingress
spec:
  replicas: 1
  strategy:
    type: Recreate # Prevents two pods claiming the same mesh identity concurrently
  selector:
    matchLabels:
      app: tailscale-ingress
  template:
    metadata:
      labels:
        app: tailscale-ingress
    spec:
      serviceAccountName: tailscale-ingress
      initContainers:
        # Enable kernel IP forwarding inside the pod network namespace
        - name: sysctler
          image: ghcr.io/tailscale/tailscale:v1.68.0
          command: ["/bin/sh", "-c"]
          args:
            - sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1
          securityContext:
            privileged: true
      containers:
        - name: tailscale
          image: ghcr.io/tailscale/tailscale:v1.68.0
          env:
            # Pre-auth key used on initial registration
            - name: TS_AUTHKEY
              valueFrom:
                secretKeyRef:
                  name: tailscale-ingress-auth
                  key: TS_AUTHKEY
                  optional: true
            - name: TS_AUTH_ONCE
              value: "true"
            # Kubernetes Secret where Tailscale node credentials & state are persisted
            - name: TS_KUBE_SECRET
              value: tailscale-ingress-state
            - name: TS_USERSPACE
              value: "false"
            # Target IP: Replace with your Traefik or Ingress controller ClusterIP
            - name: TS_DEST_IP
              value: "<TRAEFIK_SERVICE_CLUSTER_IP>"
            # Hostname visible on the Tailscale/Headscale network
            - name: TS_HOSTNAME
              value: "k8s-ingress"
            # Optional: Point to a self-hosted Headscale server
            # - name: TS_EXTRA_ARGS
            #   value: "--login-server=https://headscale.example.com --accept-dns=false"
            - name: TS_DEBUG_FIREWALL_MODE
              value: auto
            - name: TS_ENABLE_HEALTH_CHECK
              value: "true"
            - name: TS_LOCAL_ADDR_PORT
              value: "[::]:9002"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 9002
            initialDelaySeconds: 5
            periodSeconds: 5
          securityContext:
            privileged: true
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 500m
              memory: 256Mi