Headscale: Tailscale Self-Hosted para PME sem Dependência Cloud

O Tailscale revolucionou as VPNs mesh com WireGuard, mas depende de um control plane cloud gerido pela Tailscale Inc. Para pequenas e médias empresas (PME) portuguesas com requisitos de soberania de dados, compliance RGPD ou simplesmente vontade de reduzir custos recorrentes, o Headscale é a alternativa self-hosted que mantém 100% da compatibilidade com os clientes Tailscale oficiais. Este artigo mostra como instalar, configurar ACLs, integrar OIDC, activar MagicDNS e compara o custo e funcionalidades face ao plano cloud.

1. Introdução — O que é o Headscale e Quando Escolher Self-Hosted

O Headscale é uma implementação open-source do control plane do Tailscale, escrita em Go por Juan Font. Permite correr o servidor de coordenação na sua própria infraestrutura enquanto utiliza os clientes Tailscale oficiais em Windows, macOS, Linux, iOS e Android — sem alterações. A comunicação entre nós continua a usar WireGuard encriptado ponto-a-ponto; apenas o plano de controlo (troca de chaves, ACLs, DNS) passa a viver no seu servidor.

Quando escolher Headscale em vez do Tailscale cloud:

  • Sovereania de dados: nenhuma chave pública ou metadado de nó sai da sua rede — relevante para RGPD, NIS2 e sectores regulados (banca, saúde, defesa).
  • Custo fixo: o plano Tailscale Business custa $6/utilizador/mês com mínimo 10 utilizadores; o Headscale corre num VPS de €5/mês para equipas até 200 nós.
  • Controlo total de ACLs: as políticas de acesso vivem num ficheiro HuJSON no seu servidor, versionável em Git — sem dependência de uma dashboard cloud.
  • Sem vendor lock-in: se o Tailscale Inc. mudar preços ou términos de serviço, a sua rede continua a funcionar.
  • Auditabilidade: todos os logs de autenticação, aceitação de nós e mudanças de ACL ficam no seu servidor — essencial para auditorias ISO 27001 ou SOC 2.

Nota: O Headscale não é afiliado nem endossado pela Tailscale Inc. Os clientes Tailscale continuam a ser software proprietário (embora gratuitos). O Headscale é apenas o servidor de coordenação — licença BSD 3-Clause.

O requisito mínimo é um servidor com IP público (ou atrás de um reverse proxy com TLS) e um domínio ou subdomínio. Os nós clientes não precisam de qualquer configuração especial — apenas apontam para o URL do seu servidor Headscale em vez de login.tailscale.com.

2. Instalar com Docker Compose — Configuração Produção

A forma mais simples e reproduzível de correr o Headscale em produção é com Docker Compose. O exemplo abaixo usa a imagem oficial headscale/headscale, persiste a base de dados SQLite num volume, expõe a API gRPC e a interface web numa porta local (depois atrás de Caddy ou nginx com TLS automático via Let’s Encrypt).

Estrutura de directórios no servidor (ex: headscale.example.pt):

headscale/
├── docker-compose.yml
├── config/
│   └── config.yaml
├── data/              # SQLite DB + certs persistentes
└── acl/
    └── policy.hujson  # ACLs versionáveis em Git

docker-compose.yml — produção com restart policy e healthcheck:

services:
  headscale:
    image: headscale/headscale:0.23.0
    container_name: headscale
    restart: unless-stopped
    command: headscale serve
    volumes:
      - ./config:/etc/headscale/
      - ./data:/var/lib/headscale/
    ports:
      - "127.0.0.1:8080:8080"   # REST API + web UI
      - "127.0.0.1:50443:50443" # gRPC (para headscale CLI remoto)
    environment:
      - TZ=Europe/Lisbon
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3

config/config.yaml — os parâmetros críticos a definir (valores restantes podem ficar por defeito):

server_url: https://headscale.example.pt
listen_addr: 0.0.0.0:8080
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
private_key_path: /var/lib/headscale/private.key
ip_prefixes:
  - fd7a:115c:a1e0::/48
  - 100.64.0.0/10
magic_dns:
  enabled: true
  base_domain: tail.example.pt
  use_username_in_magic_dns: true
policy:
  path: /etc/headscale/policy.hujson
log_level: info
log_format: text

O reverse proxy com Caddy (TLS automático Let’s Encrypt) — coloque num Caddyfile adjacente ou no mesmo Compose:

headscale.example.pt {
    reverse_proxy localhost:8080
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
    }
}

Arrancar e criar o primeiro utilizador (namespace no Headscale 0.23 chama-se “user”):

docker compose up -d
docker compose exec headscale headscale users create devops
# Gerar chave de registo pré-autorizada (válida 1h):
docker compose exec headscale headscale preauthkeys create \
  --user devops --reusable --expiration 1h

Num cliente (Linux, Windows, macOS) com o Tailscale instalado, apontar para o servidor próprio e registar:

# Linux: definir o login server
tailscale up --login-server https://headscale.example.pt \
  --auth-key tskey-xxxx-yyyy
# Windows: adicionar --login-server na linha de comando
# ou definir em HKLM\SOFTWARE\Tailscale IPN\LoginURL

Dica: Para clientes iOS/Android, a app Tailscale oficial permite definir o login server em Settings > Custom server. Em macOS, usar tailscale up --login-server no Terminal.

3. ACLs e OIDC — Controlo de Acesso e Autenticação Centralizada

As ACLs do Headscale usam o mesmo formato HuJSON do Tailscale — a mesma semântica de tagOwners, autoApprovers e acls com src/dst/action. Exemplo de policy.hujson para uma PME com departamentos:

{
  "tagOwners": {
    "tag:srv":     ["devops"],
    "tag:employee":["devops"],
    "tag:guest":   ["devops"],
  },
  "autoApprovers": {
    "exitNode": ["tag:srv"],
  },
  "acls": [
    // Devops: acesso total
    { "action": "accept", "src": ["devops"], "dst": ["*:*"] },
    // Funcionários: apenas servidores nas portas 22, 80, 443, 3389
    { "action": "accept", "src": ["tag:employee"],
      "dst": ["tag:srv:22,80,443,3389"] },
    // Convidados: só HTTP/HTTPS para um servidor web específico
    { "action": "accept", "src": ["tag:guest"],
      "dst": ["tag:srv:80,443"] },
    // Negar todo o resto (implícito, mas explícito para auditoria)
    { "action": "deny", "src": ["*"], "dst": ["*:*"] },
  ],
}

Para aplicar sem reiniciar o container:

docker compose exec headscale headscale policy check
# Valida sintaxe antes de aplicar. Sem erros, o Headscale
# recarrega automaticamente quando o ficheiro muda (inotify).

Para autenticação centralizada, o Headscale integra com qualquer provider OIDC (Keycloak, Authentik, Authelia, Zitadel, Azure AD/Entra ID, Google Workspace). Configuração em config.yaml:

oidc:
  enabled: true
  issuer: https://auth.example.pt/realms/empresa
  client_id: headscale
  client_secret: 
  scope: ["openid", "profile", "email"]
  # Mapear grupos do IdP para tags Headscale
  strip_email_domain: true
  # Se o IdP não enviar email_verified, definir:
  # only_start_if_oidc_user_info_is_valid: true

Com OIDC activo, os utilizadores autenticam-se no provider (ex: Keycloak com MFA) e o Headscale atribui-lhes automáticamente máquinas e tags conforme os grupos do IdP. Isto elimina as chaves pré-autorizadas para utilizadores finais — ficam reservadas para servers e automação CI/CD.

Atenção: O callback URL a registar no IdP é https://headscale.example.pt/oidc/callback. Se o Headscale estiver atrás de reverse proxy, garantir que o header X-Forwarded-Proto chega correctamente, senão o redirect entra em loop.

4. MagicDNS e Comparação com Tailscale Cloud

O MagicDNS no Headscale funciona como no Tailscale cloud: cada nó recebe um nome DNS dentro do base_domain definido (ex: servidor1.tail.example.pt resolve para o IP 100.64.x.x do nó dentro da tailnet). Para que os clientes resolvam MagicDNS, dois caminhos:

  1. Automático via Tailscale: o cliente instala um resolver DNS local que intercepta queries do base_domain. Funciona sem configuração adicional na maioria dos sistemas operativos.
  2. DNS condicional no router/ad-dc: encaminhar queries para *.tail.example.pt para o IP do servidor Headscale. Útil em ambientes Active Directory onde se quer centralizar DNS.

Comparação directa entre Headscale self-hosted e Tailscale Cloud (planos Personal e Business):

Funcionalidade Headscale (Self-Hosted) Tailscale Cloud Business
Custo (20 utilizadores) €5–15/mês (VPS) $120/mês ($6 × 20)
Soberania de dados Total (on-prem ou VPS PT/EU) Servidores EUA/UE (Tailscale Inc.)
Limite de dispositivos/utilizador Ilimitado 100 (Business)
ACLs (HuJSON) Sim, ficheiro local versionável Sim, via dashboard ou ACL file
OIDC/SAML OIDC (Keycloak, Authentik, Entra ID) OIDC, SAML, SCIM (mais providers nativos)
Exit nodes Sim Sim + Mullvad exit nodes
Subnet routers Sim Sim + HA subnet routers
Funnel (expor serviços públics) Experimental (0.23+) Sim, estável
Suporte Comunidade GitHub + Discord Suporte empresarial 24/7
SLA Nenhum (auto-gerido) 99.9% (Business)
Atualizações Manual (docker pull) Automáticas, transparentes

O ponto de equilíbrio económico: para uma equipa de 20 utilizadores, o Headscale poupa ~€1.380/ano face ao plano Business. Para 50 utilizadores, a poupança sobe para ~€3.450/ano. A contrapartida é o esforço operacional de manter o servidor actualizado, monitorizar logs e gerir backups da base de dados SQLite.

5. Erros Comuns e Soluções

Durante a instalação e operação do Headscale, os erros mais frequentes e como os resolver:

Problema Causa Solução
Cliente não regista: “dial tcp: connection refused” URL do servidor incorrecto ou reverse proxy sem TLS Verificar server_url em config.yaml; confirmar que Caddy/nginx tem certificado válido; testar curl https://headscale.example.pt/health
“failed to authenticate: oidc callback: state mismatch” Headers X-Forwarded-Proto ausentes no reverse proxy No Caddyfile adicionar header_up X-Forwarded-Proto {scheme} no reverse_proxy; no nginx garantir proxy_set_header X-Forwarded-Proto $scheme
MagicDNS não resolve nomes Cliente com DNS sistemico configurado (ex: systemd-resolved sem integração) Confirmar magic_dns.enabled: true em config.yaml; no Linux correr tailscale up --accept-dns=true; verificar com tailscale status que o nó mostra “100.x.x.x”
ACLs não aplicam após editar policy.hujson Erro de sintaxe HuJSON ou ficheiro fora do path esperado Correr headscale policy check dentro do container; confirmar que policy.path em config.yaml aponta para o ficheiro montado
“context deadline exceeded” no headscale users create gRPC a escutar em 0.0.0.0 mas CLI a tentar localhost sem TLS Definir HEADSCALE_GRPC_ADDR ou usar docker compose exec que executa dentro do container onde o endereço é local
Nós ficam “offline” sem motivo aparente Deriva de relógio entre cliente e servidor (WireGuard exige sincronia < 180s) Verificar NTP/chrony em ambos os lados com chronyc tracking ou timedatectl status; instalar systemd-timesyncd se não estiver activo
Base de dados SQLite corrompida após OOM Container morto a meio de write sem WAL checkpoint Parar o container, correr sqlite3 data/headscale.db ".recover" para extrair dados; em produção usar PostgreSQL (suportado desde 0.22) ou backup automático do volume
Clientes iOS/Android não conseguem entrar na tailnet App Tailscale móvel não suporta custom login server sem configuração prévia Em Settings > Custom server, introduzir o URL do Headscale; em MDM (Intune/Jamf) pré-configurar via LoginURL key

Boa prática: Activar backups automáticos do volume ./data (cron + restic para S3/B2) e monitorizar o endpoint /health com Uptime Kuma ou Checkmk. Sem o control plane, nenhum nó novo se pode registar — é o ponto único de falha da arquitectura self-hosted.

O Headscale atinge maturidade de produção para PME que valorizam soberania, custo controlado e flexibilidade de configuração. A compatibilidade total com clientes Tailscale oficiais significa que a migração do cloud para self-hosted (ou vice-versa) é apenas mudar o --login-server — sem reinstalar software nos endpoints.