Tailscale, c’est bien. L’idée de reposer sur des serveurs tiers pour son réseau mesh, un peu moins. Headscale est l’implémentation open source du serveur de contrôle Tailscale : on garde les clients officiels, on remplace le plan de contrôle par quelque chose qu’on héberge soi-même.

Point de départ : permettre un accès distant sécurisé aux serveurs d’une école. Pas envie de leur ouvrir des ports en direct ou de faire confiance à un service cloud tiers pour la gestion du réseau. Un petit VPS à 3-4€/mois avec Headscale, et les machines de l’école deviennent accessibles depuis n’importe où, sans exposition publique.

  • Headscale pour le plan de contrôle
  • Headplane pour l’interface d’administration
  • Authelia pour l’authentification
  • Caddy comme reverse proxy
  • Le tout dans Docker Compose sur un VPS

Ce qu’on construit

~/workshop/headscale/
 ├── headscale/       # config Headscale
 ├── headplane/       # config Headplane (webui)
 ├── authelia/        # config Authelia
 ├── caddy/           # Caddyfile
 └── docker-compose.yml

J’ai utilisé un sous-domaine d’un domaine que j’avais déjà. Caddy s’occupe des certificats Let’s Encrypt tout seul.


Headscale

Le conteneur

# docker-compose.yml
name: "headscale-stack"
services:
  headscale:
    image: headscale/headscale:0.25.1
    restart: unless-stopped
    container_name: headscale
    ports:
      - "0.0.0.0:8080:8080"
      - "127.0.0.1:9090:9090"   # metrics/debug, interne uniquement
    environment:
      - TZ=Europe/Paris
    volumes:
      - ./headscale:/etc/headscale:ro
      - headscale_data_lib:/var/lib/headscale:rw
      - headscale_data_run:/var/run/headscale:rw
    command: serve

volumes:
  headscale_data_lib:
  headscale_data_run:

Fixer la version (bonne pratique dans la plupart des cas) évite les surprises : il y a eu plusieurs incompatibilités entre versions mineures. La dernière version est sur le repo GitHub.

La configuration

On récupère le fichier exemple depuis le repo (en adaptant la version) :

wget -O ./headscale/config.yaml \
  https://raw.githubusercontent.com/juanfont/headscale/v0.25.1/config-example.yaml

Ce qui est important à changer :

server_url: https://headscale.mondomaine.fr:443
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential

database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite
    write_ahead_log: true

acme_email: "votre@email.fr"

dns:
  magic_dns: true
  base_domain: mondomaine.fr   # suffixe pour les FQDN des clients, doit être différent du domaine Headscale
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9

unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

logtail:
  enabled: false   # désactive l'envoi de logs vers Tailscale Inc.

Le reste peut rester en valeurs par défaut pour commencer.


Headplane (webui)

Headplane est l’interface d’administration officielle de Headscale. Elle s’appuie sur le socket Unix de Headscale via gRPC.

# docker-compose.yml (suite)
  headplane:
    image: ghcr.io/tale/headplane:latest
    restart: unless-stopped
    container_name: headplane
    volumes:
      - headscale_data_run:/var/run/headscale:ro
      - ./headplane/config.yaml:/etc/headplane/config.yaml:ro
    depends_on:
      - headscale

Configuration minimale ./headplane/config.yaml :

headscale:
  url: http://headscale:8080
  config_path: /etc/headscale/config.yaml

integration:
  headscale_socket: /var/run/headscale/headscale.sock

Headplane expose son interface sur le port 3000 par défaut. Caddy s’en occupe ensuite.


Authelia

Authelia protège l’interface Headplane avec une couche SSO. Headscale peut aussi s’y brancher via OIDC pour l’enregistrement des clients, mais c’est optionnel.

Secrets

mkdir -p authelia/.secrets
openssl rand -base64 32 > authelia/.secrets/jwt_secret
openssl rand -base64 32 > authelia/.secrets/session_secret
openssl rand -base64 32 > authelia/.secrets/storage_encryption_key

Le conteneur

# docker-compose.yml (suite)
  authelia:
    image: authelia/authelia:latest
    restart: unless-stopped
    container_name: authelia
    volumes:
      - ./authelia/config:/config
      - ./authelia/.secrets:/secrets:ro
    environment:
      - AUTHELIA_JWT_SECRET_FILE=/secrets/jwt_secret
      - AUTHELIA_SESSION_SECRET_FILE=/secrets/session_secret
      - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/storage_encryption_key

Configuration ./authelia/config/configuration.yaml

server:
  host: 0.0.0.0
  port: 9091

log:
  level: info

authentication_backend:
  file:
    path: /config/users_database.yml

access_control:
  default_policy: deny
  rules:
    - domain: headplane.mondomaine.fr
      policy: two_factor

session:
  name: authelia_session
  domain: mondomaine.fr
  expiration: 1h
  inactivity: 5m

storage:
  local:
    path: /config/db.sqlite3

notifier:
  filesystem:
    filename: /config/notification.txt   # remplacer par smtp en prod

Créer un utilisateur

docker run authelia/authelia:latest \
  authelia crypto hash generate argon2 \
  --random --random.length 20 --random.charset alphanumeric

Le hash va dans ./authelia/config/users_database.yml :

users:
  florent:
    displayname: "Florent"
    password: "$argon2id$..."
    email: votre@email.fr
    groups:
      - admins

Caddy (reverse proxy)

# docker-compose.yml (suite)
  caddy:
    image: caddy:latest
    restart: unless-stopped
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

./caddy/Caddyfile :

headscale.mondomaine.fr {
  reverse_proxy headscale:8080
}

headplane.mondomaine.fr {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://auth.mondomaine.fr
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
  reverse_proxy headplane:3000
}

auth.mondomaine.fr {
  reverse_proxy authelia:9091
}

Démarrage

docker network create frontend
docker compose up -d

Créer une clé API pour Headplane :

docker exec headscale headscale apikeys create

Connecter des clients

Linux / macOS

PRE_AUTH_KEY=$(docker exec headscale headscale preauthkeys create --user florent)

tailscale up \
  --auth-key=$PRE_AUTH_KEY \
  --login-server=https://headscale.mondomaine.fr \
  --accept-routes \
  --accept-dns=false

Windows (PowerShell admin)

$AuthKey = "votre_preauth_key"
Start-Process powershell -Verb runAs -ArgumentList `
  "net stop Tailscale; `
   Remove-Item -Force -Recurse $env:LOCALAPPDATA\Tailscale; `
   net start Tailscale; `
   tailscale logout; `
   tailscale up --auth-key=$AuthKey --login-server=https://headscale.mondomaine.fr --accept-routes --unattended; exit"

Exposer un subnet

Pour exposer un réseau local (ex: 192.168.10.0/24) aux autres clients :

# Sur la machine qui fait passerelle
tailscale up --advertise-routes=192.168.10.0/24 \
  --login-server=https://headscale.mondomaine.fr \
  --auth-key=<key>

# Autoriser la route côté serveur
docker exec headscale headscale routes list
docker exec headscale headscale routes enable -r <id>

Notes

La stack est délibérément simple : SQLite, fichiers locaux, pas de DERP custom. Ca tourne sur un petit VPS sans problème pour un usage personnel ou une petite équipe.

Quelques variantes si ça ne colle pas avec l’existant :

  • Remplacer Caddy par Traefik (plus flexible pour du multi-service existant)
  • Utiliser Cloudflare Tunnel à la place du VPS exposé
  • Passer à PostgreSQL si on a besoin de réplication
  • Brancher Headscale sur un OIDC externe plutôt qu’Authelia (Keycloak, Authentik…)