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:
- 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. - DNS condicional no router/ad-dc: encaminhar queries para
*.tail.example.ptpara 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.