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
- 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. - One-Time Authentication (
TS_AUTH_ONCE=true): Uses an auth key only on first registration; subsequent boots rely on the saved secret. - Transparent IP Forwarding (
TS_DEST_IP): In kernel-networking mode, settingTS_DEST_IPinstructs the container to forward all incoming connections to the target IP (the cluster IP of Traefik) without port translation. - Recreate Strategy: Setting
strategy: type: Recreateensures 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