Skip to content

infradohq/headscale-operator

Repository files navigation

Headscale Operator

A Kubernetes operator for managing Headscale - an open source, self-hosted implementation of the Tailscale control server.

Overview

The Headscale Operator simplifies the deployment and management of Headscale instances on Kubernetes. It provides a declarative, GitOps-friendly way to configure and deploy Headscale with all its configuration options through Kubernetes Custom Resources.

Features

  • Declarative Configuration: Define your entire Headscale setup as a Kubernetes Custom Resource
  • Automatic Deployment: Manages StatefulSets, Services, ConfigMaps, and PersistentVolumes
  • API Key Management: Automatic API key creation and rotation with configurable expiration
  • ACL Policy Management: Declarative auto-approval of subnet routes and exit nodes via the HeadscaleAutoApprover CRD
  • Full Config Support: Supports all Headscale configuration options including:
    • Database configuration (SQLite/PostgreSQL)
    • DERP server configuration
    • DNS and MagicDNS settings
    • OIDC authentication
    • TLS/Let's Encrypt integration
    • Policy configuration
  • Observability: Built-in metrics endpoint for monitoring
  • Production Ready: Supports high availability with persistent storage

Table of Contents

Getting Started

Prerequisites

  • Go 1.25.0+
  • Docker 17.03+
  • kubectl 1.11.3+
  • Access to a Kubernetes 1.11.3+ cluster

Installation

Using Helm (Recommended)

helm install headscale-operator oci://ghcr.io/infradohq/headscale-operator/charts/headscale-operator:$LATEST_VERSION

Quick Start

  1. Create a namespace for Headscale:
kubectl create namespace headscale
  1. Create a Headscale instance:
apiVersion: headscale.infrado.cloud/v1beta1
kind: Headscale
metadata:
  name: headscale-sample
  namespace: headscale
spec:
  version: "v0.28.0"
  replicas: 1
  config:
    server_url: http://vpn.headscale.local
    grpc_allow_insecure: true
    derp:
      server:
        enabled: false    
    disable_check_updates: false
    database:
      type: sqlite
    dns:
      magic_dns: false
  # Automatic API key management (optional)
  api_key:
    auto_manage: true  # Automatically create and rotate API keys
    manager_image: headscale-api-key
    expiration: "2160h"      # API key expires in 90 days (2160 hours)
    rotation_buffer: "240h"   # Rotate 10 days (240 hours) before expiration

Apply the configuration:

kubectl apply -f config/samples/headscale_v1beta1_headscale.yaml

Or use kustomize:

kubectl apply -k config/samples/

Usage

The operator will automatically create and manage the following resources:

  • A StatefulSet running Headscale
  • A ConfigMap with the Headscale configuration
  • Services for HTTP, gRPC, and metrics endpoints
  • PersistentVolumeClaims for data storage
  • API key management sidecar (if enabled)
  • Kubernetes Secret with the API key (if auto-managed)

Managing Users

The operator provides the HeadscaleUser custom resource to manage users in your Headscale instance.

Creating a User

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleUser
metadata:
  name: alice
  namespace: headscale
spec:
  # Reference to the Headscale instance
  headscaleRef: headscale-sample
  
  # Username (immutable after creation)
  username: alice
  
  # Optional: Display name for the user
  displayName: Alice Smith
  
  # Optional: Email address
  email: alice@example.com
  
  # Optional: Profile picture URL
  pictureURL: https://example.com/alice.jpg

Apply the user:

kubectl apply -f headscaleuser.yaml

User Properties

  • username: Must be unique and follow DNS label rules (lowercase alphanumeric with hyphens). This field is immutable after creation.
  • displayName: Human-readable name (max 255 characters). Immutable after creation.
  • email: Valid email address (max 320 characters). Immutable after creation.
  • pictureURL: HTTP(S) URL to profile picture (max 2048 characters). Immutable after creation.

Viewing Users

# List all users
kubectl get headscaleuser -n headscale

# Get user details
kubectl get headscaleuser alice -n headscale -o yaml

# View user status including Headscale UserID
kubectl get headscaleuser alice -n headscale -o jsonpath='{.status.userId}'

Managing PreAuth Keys

The operator provides the HeadscalePreAuthKey custom resource to manage pre-authentication keys for registering nodes.

Creating a PreAuth Key

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscalePreAuthKey
metadata:
  name: dev-key
  namespace: headscale
spec:
  # Reference to the Headscale instance
  headscaleRef: headscale-sample
  
  # Reference to a HeadscaleUser resource
  headscaleUserRef: alice
  
  # Alternatively, specify user ID directly:
  # userId: 1
  
  # Key expires after 24 hours
  expiration: "24h"
  
  # Can be used only once (set to true for multiple uses)
  reusable: false
  
  # Creates ephemeral nodes (automatically removed when disconnected)
  ephemeral: false
  
  # Automatically assign tags to nodes using this key
  tags:
    - "tag:dev"
    - "tag:laptop"
  
  # Optional: Secret name (defaults to resource name)
  secretName: alice-dev-key

Apply the preauth key:

kubectl apply -f headscalepreauthkey.yaml

PreAuth Key Properties

  • headscaleRef: Name of the Headscale instance (required)
  • headscaleUserRef: Name of the HeadscaleUser resource (use this OR userId)
  • userId: Numeric user ID from Headscale (use this OR headscaleUserRef)
  • expiration: Duration string (e.g., "30m", "24h", "1h30m"). Default: "1h"
  • reusable: Whether key can be used multiple times. Default: false
  • ephemeral: Whether nodes should be ephemeral. Default: false
  • tags: List of tags to assign (format: "tag:name")
  • secretName: Name of the Kubernetes secret to store the key. Defaults to the resource name.

Retrieving PreAuth Keys

The generated preauth key is stored in a Kubernetes Secret:

# Get the preauth key
kubectl get secret alice-dev-key -n headscale -o jsonpath='{.data.key}' | base64 -d

# View all preauth keys
kubectl get headscalepreauthkey -n headscale

# View details
kubectl get headscalepreauthkey dev-key -n headscale -o yaml

Using PreAuth Keys

Use the retrieved key to register a new node to your Headscale network:

# Get the key
KEY=$(kubectl get secret alice-dev-key -n headscale -o jsonpath='{.data.key}' | base64 -d)

# Register a node using Tailscale client
tailscale up --login-server=https://headscale.example.com --authkey=$KEY

PreAuth Key Examples

One-time use key for a single device:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscalePreAuthKey
metadata:
  name: laptop-key
spec:
  headscaleRef: headscale-sample
  headscaleUserRef: alice
  expiration: "1h"
  reusable: false

Reusable key for multiple CI/CD runners:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscalePreAuthKey
metadata:
  name: ci-runner-key
spec:
  headscaleRef: headscale-sample
  headscaleUserRef: ci-user
  expiration: "720h"  # 30 days
  reusable: true
  ephemeral: true     # Auto-cleanup disconnected runners
  tags:
    - "tag:ci"
    - "tag:ephemeral"

Key for temporary test environments:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscalePreAuthKey
metadata:
  name: test-env-key
spec:
  headscaleRef: headscale-sample
  userId: 5
  expiration: "2h"
  ephemeral: true
  tags:
    - "tag:test"

Managing Auto-Approve Routes

The operator provides the HeadscaleAutoApprover custom resource to declaratively manage auto-approved subnet routes and exit nodes in your Headscale instance. Each HeadscaleAutoApprover contributes entries to the parent Headscale's policy document; the operator merges all auto-approvers targeting a Headscale and pushes the result via the gRPC SetPolicy API.

Prerequisites

Auto-approval lives in the Headscale policy document, which the operator can only write when Headscale is configured with policy.mode: database. The parent Headscale resource must also declare the tag owners that the auto-approver references. Both fields are set on the Headscale spec:

apiVersion: headscale.infrado.cloud/v1beta1
kind: Headscale
metadata:
  name: headscale-sample
  namespace: headscale
spec:
  # ...
  config:
    policy:
      mode: database          # required for SetPolicy
  acl_policy:
    tag_owners:
      "tag:router": ["admin@example.com"]
      "tag:exit":   ["admin@example.com"]
    inline: |                 # optional base policy for acls/groups/hosts/ssh
      {
        "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
      }

acl_policy.inline accepts JSON or HuJSON (comments and trailing commas are fine). The operator parses it, merges in tag_owners and any HeadscaleAutoApprover entries, then pushes the result via SetPolicy. The CR is the source of truth — headscale policy get shows the rendered output as strict JSON.

Creating an Auto-Approver

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleAutoApprover
metadata:
  name: k8s-network
  namespace: headscale
spec:
  # Reference to the Headscale instance (same namespace)
  headscaleRef: headscale-sample

  # Routes a node carrying any of the listed tags will have
  # auto-approved when announced via --advertise-routes.
  routes:
    - cidr: 10.10.0.0/16
      tags: ["tag:router"]

  # Tags whose nodes will be auto-approved as exit nodes.
  exitNodeTags: ["tag:exit"]

Apply:

kubectl apply -f headscaleautoapprover.yaml

Auto-Approver Properties

  • headscaleRef: Name of the Headscale instance in the same namespace. Required.
  • routes: List of {cidr, tags} pairs. A node registered with one of the listed tags has the matching CIDR auto-approved when it advertises that route. Each tag must be declared in the parent's acl_policy.tag_owners.
  • exitNodeTags: List of tags whose nodes are auto-approved as exit nodes when they advertise themselves as such.

At least one of routes or exitNodeTags must be specified. Multiple HeadscaleAutoApprover resources may target the same Headscale; the operator merges them deterministically into a single autoApprovers block.

Viewing Auto-Approvers

# List all auto-approvers in a namespace
kubectl get headscaleautoapprover -n headscale

# Inspect the Ready condition (status=True / reason=PolicyApplied means push succeeded)
kubectl get headscaleautoapprover k8s-network -n headscale \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")]}'

# Inspect the live merged policy stored in Headscale
kubectl exec -n headscale -c headscale <headscale-pod> -- headscale policy get

The Ready condition reasons are:

  • PolicyApplied — the merged policy was successfully pushed via gRPC.
  • HeadscaleNotFound — the referenced Headscale doesn't exist in the same namespace.
  • PolicyModeUnsupported — the parent Headscale isn't configured with policy.mode: database.
  • PolicyPushFailed — the gRPC SetPolicy call returned an error (see .status.conditions[].message for details).

Combining With PreAuth Keys

An auto-approver only takes effect when a node is actually carrying the matching tag. The most common pattern is to issue a tagged preauth key for the subnet router:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscalePreAuthKey
metadata:
  name: subnet-router-key
  namespace: headscale
spec:
  headscaleRef: headscale-sample
  headscaleUserRef: alice
  reusable: true
  tags: ["tag:router"]

A node registered with this key, advertising e.g. --advertise-routes=10.10.0.0/16, will have its routes auto-approved.

Auto-Approver Examples

Auto-approve a Kubernetes pod CIDR via a subnet router:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleAutoApprover
metadata:
  name: pod-network
spec:
  headscaleRef: headscale-sample
  routes:
    - cidr: 10.244.0.0/16
      tags: ["tag:router"]

Auto-approve a tagged exit node:

apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleAutoApprover
metadata:
  name: home-exit-node
spec:
  headscaleRef: headscale-sample
  exitNodeTags: ["tag:exit"]

Per-team segmentation — each team owns its own CIDR:

---
apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleAutoApprover
metadata:
  name: team-prod-routes
  namespace: headscale
spec:
  headscaleRef: headscale-sample
  routes:
    - cidr: 10.10.0.0/16
      tags: ["tag:router"]
---
apiVersion: headscale.infrado.cloud/v1beta1
kind: HeadscaleAutoApprover
metadata:
  name: team-staging-routes
  namespace: headscale
spec:
  headscaleRef: headscale-sample
  routes:
    - cidr: 10.20.0.0/16
      tags: ["tag:router"]

The operator merges both into a single autoApprovers.routes map and pushes one consolidated policy.

API Key Management

When API key auto-management is enabled, the sidecar creates a Kubernetes Secret containing the API key:

# Get the API key
kubectl get secret headscale-api-key -n headscale -o jsonpath='{.data.api-key}' | base64 -d

# View full secret details
kubectl get secret headscale-api-key -n headscale -o yaml

The secret contains:

  • api-key: The actual API key for authenticating with Headscale
  • expiration: When the API key will expire (RFC3339 format)
  • created-at: When the API key was created (RFC3339 format)

For more details on API key management, see cmd/apikey-manager/README.md.

Uninstallation

Delete the Headscale instance:

kubectl delete -k config/samples/

To completely remove the operator:

helm uninstall headscale-operator

Development

Building from Source

# Build the operator binary
make build

# Run tests
make test

# Build Docker image
make docker-build IMG=<registry>/headscale-operator:tag

Running Locally

# Install CRDs
make install

# Run the operator locally (outside the cluster)
make run

# In another terminal, create a sample Headscale instance
kubectl apply -f config/samples/headscale_v1beta1_headscale.yaml

Deploying to Cluster

# Build and push image
make docker-build docker-push IMG=<registry>/headscale-operator:tag

# Deploy to cluster
make deploy IMG=<registry>/headscale-operator:tag

Running Tests

# Run unit tests
make test

# Run end-to-end tests
make test-e2e

Contributing

We welcome contributions! Here's how you can help:

  1. Fork the repository and create your branch from main
  2. Make your changes and add tests if applicable
  3. Ensure tests pass by running make test
  4. Format your code with make fmt and make vet
  5. Commit your changes using conventional commits
  6. Open a pull request with a clear description of your changes

Acknowledgments

Packages

 
 
 

Contributors