DEV Community

david
david

Posted on • Originally published at woitzik.dev

Self-Hosted Tailscale Control Plane: Headscale on k3s with Authelia OIDC

Originally published at woitzik.dev

Tailscale solves the "access everything from anywhere" problem better than any VPN I've used. The client experience is excellent. The problem is the control plane: your device list, user identities, and ACL policies all live on Tailscale's servers.

Headscale is a self-hosted, open-source implementation of the Tailscale control plane. Same WireGuard mesh, same clients — but your data stays on your infrastructure. If you're already running k3s with ArgoCD, adding Headscale is straightforward.

View the complete homelab infrastructure source on GitHub 🐙

The Architecture

Tailscale Client (any device)
        │
        ▼
Traefik IngressRoute (headscale.yourdomain.com)
        │
        ▼
Headscale Service (port 8080)
        │
        ├── Auth: Authelia OIDC (auth.yourdomain.com)
        ├── State: SQLite on Longhorn PVC
        └── DERP: Tailscale's relay servers (external)
Enter fullscreen mode Exit fullscreen mode

Headscale handles device registration and key exchange. All actual traffic flows peer-to-peer over WireGuard — the control plane is not in the data path.

Step 1: Persistent Storage with Longhorn

Headscale stores its private keys and SQLite database on disk. A pod restart must not lose these — they're the root of trust for your entire WireGuard mesh.

# kubernetes/apps/headscale/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: headscale-data
  namespace: apps
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 5Gi
Enter fullscreen mode Exit fullscreen mode

ReadWriteOnce is correct here — Headscale is a single-replica deployment.

Step 2: Headscale Configuration

The full configuration is stored in a ConfigMap. Key sections:

# kubernetes/apps/headscale/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: headscale-config
  namespace: apps
data:
  config.yaml: |
    server_url: https://headscale.yourdomain.com
    listen_addr: 0.0.0.0:8080

    private_key_path: /var/lib/headscale/private.key
    noise:
      private_key_path: /var/lib/headscale/noise_private.key

    db_type: sqlite3
    db_path: /var/lib/headscale/db.sqlite

    derp:
      server:
        enabled: false
      urls:
        - https://controlplane.tailscale.com/derpmap/default
      auto_update_enabled: true
      update_frequency: 24h

    dns_config:
      magic_dns: true
      base_domain: headscale.net
      nameservers:
        - 10.0.20.5       # your internal DNS resolver
      extra_records: []

    oidc:
      issuer: "https://auth.yourdomain.com"
      client_id: "headscale"
      client_secret: "<your-oidc-client-secret>"
      scope: ["openid", "profile", "email"]
      strip_email_domain: true

    ip_prefixes:
      - 100.64.0.0/10     # Tailscale CGNAT range

    policy_path: /var/lib/headscale/policy.hujson
Enter fullscreen mode Exit fullscreen mode

DERP relay is left to Tailscale's infrastructure (controlplane.tailscale.com/derpmap/default). Running your own DERP server adds operational overhead for minimal benefit in a homelab.

OIDC delegates authentication to Authelia. When a new device registers, the user authenticates via the Authelia web UI — no separate Headscale user management needed.

ip_prefixes: 100.64.0.0/10 is the standard Tailscale CGNAT range. Clients in your mesh will receive addresses from this space.

Step 3: The Deployment

# kubernetes/apps/headscale/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: headscale
  namespace: apps
spec:
  replicas: 1
  strategy:
    type: Recreate        # never two instances with the same SQLite file
  selector:
    matchLabels:
      app: headscale
  template:
    metadata:
      labels:
        app: headscale
    spec:
      containers:
        - name: headscale
          image: ghcr.io/juanfont/headscale:0.22.3
          args:
            - headscale
            - serve
          ports:
            - name: http
              containerPort: 8080
            - name: metrics
              containerPort: 9090
          volumeMounts:
            - name: config
              mountPath: /etc/headscale/config.yaml
              subPath: config.yaml
              readOnly: true
            - name: data
              mountPath: /var/lib/headscale
      volumes:
        - name: config
          configMap:
            name: headscale-config
        - name: data
          persistentVolumeClaim:
            claimName: headscale-data
Enter fullscreen mode Exit fullscreen mode

strategy: Recreate is critical. With SQLite, two pods writing simultaneously would corrupt the database. Recreate kills the old pod before starting the new one — no rolling update.

Step 4: Service and IngressRoute

# kubernetes/apps/headscale/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: headscale
  namespace: apps
spec:
  ports:
    - port: 8080
      targetPort: 8080
      name: http
  selector:
    app: headscale
---
# kubernetes/apps/headscale/ingress.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: headscale
  namespace: apps
spec:
  entryPoints: [websecure]
  routes:
    - match: Host(`headscale.yourdomain.com`)
      kind: Rule
      services:
        - name: headscale
          port: 8080
  tls:
    secretName: wildcard-yourdomain-tls
Enter fullscreen mode Exit fullscreen mode

Headscale does not need an Authelia middleware on the IngressRoute — authentication is handled internally by the OIDC flow. The dashboard endpoint should be protected separately if exposed.

Step 5: Authelia OIDC Client

In your Authelia configuration, add the Headscale client:

identity_providers:
  oidc:
    clients:
      - client_id: headscale
        client_name: Headscale
        client_secret: "<bcrypt-hash-of-your-secret>"
        public: false
        authorization_policy: one_factor
        redirect_uris:
          - https://headscale.yourdomain.com/oidc/callback
        scopes:
          - openid
          - profile
          - email
Enter fullscreen mode Exit fullscreen mode

The client_secret in Authelia must be the bcrypt hash of the plaintext secret in the Headscale config. Authelia validates the hash on the OIDC callback.

Step 6: ArgoCD Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: headscale
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/yourusername/homelab-infrastructure.git
    targetRevision: main
    path: kubernetes/apps/headscale
  destination:
    server: https://kubernetes.default.svc
    namespace: apps
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
Enter fullscreen mode Exit fullscreen mode

Registering a Client

Once Headscale is running, register clients using the standard Tailscale client with a custom login server:

# Linux / macOS
tailscale up --login-server https://headscale.yourdomain.com

# On a machine where tailscale is already running
tailscale login --login-server https://headscale.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

The client opens a browser to the OIDC callback URL. After authenticating via Authelia, the device is registered and receives a 100.64.x.x IP from the mesh.

Access Control Policy

Headscale supports HuJSON ACL policies at policy_path. A minimal policy allowing all nodes to communicate:

{
  "acls": [
    {
      "action": "accept",
      "src": ["*"],
      "dst": ["*:*"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

For tighter control, you can restrict access by user or tag — the same policy syntax as Tailscale's ACLs.

The Result

  • Every device enrolled in Headscale can reach every other device over WireGuard, regardless of NAT or firewall
  • Authentication goes through Authelia — no separate Headscale user accounts
  • The database and keys are on Longhorn, backed up daily by Velero
  • The entire deployment is a git push away from any machine

Running a self-hosted control plane is one more service to operate, but the trade-off is worth it if data sovereignty matters to you. The WireGuard mesh gives you a flat private network across all your devices — useful for reaching homelab services from anywhere without exposing anything to the public internet.


Zero-Trust network access is the same problem in Azure — just solved with Private Link and Managed Identity instead of WireGuard. If you're building that layer for a regulated Azure environment, the Enterprise Terraform Blueprints cover it.

Top comments (0)