Hetzner VM — Provisionamento Completo
Wizard que provisiona do zero: copia templates → chama /linux-vm-hardening → terraform apply.
Estado atual do ambiente
echo "=== Ferramentas ===" \
&& (terraform version 2>/dev/null | head -1 || tofu version 2>/dev/null | head -1 || echo "terraform/tofu: NAO INSTALADO") \
&& (hcloud version 2>/dev/null | head -1 || echo "hcloud: nao instalado (opcional)") \
&& echo "" && echo "=== Projeto ===" \
&& (ls infra/hetzner/ 2>/dev/null && echo "infra/hetzner/ ja existe" || echo "infra/hetzner/ sera criado") \
&& (ls infra/hetzner/terraform.tfvars 2>/dev/null && echo "terraform.tfvars: JA EXISTE" || echo "terraform.tfvars: sera criado") \
&& (ls infra/hetzner/cloud-init.yaml 2>/dev/null && echo "cloud-init.yaml: JA EXISTE" || echo "cloud-init.yaml: sera gerado") \
&& echo "" && echo "=== Seu IP publico ===" && (curl -s --max-time 3 ifconfig.me || echo "nao obtido") \
&& echo "" && echo "=== SSH Key ===" \
&& (cat ~/.ssh/id_ed25519.pub 2>/dev/null || echo "~/.ssh/id_ed25519.pub nao encontrada")
Passo 1 — Ferramentas
Se terraform/tofu não instalado, parar e orientar:
- OpenTofu (recomendado):
snap install --classic opentofuoubrew install opentofu - Terraform:
brew install terraform
hcloud CLI é opcional mas útil para validar o token:
brew install hcloudou https://github.com/hetznercloud/cli/releases
Passo 2 — Copiar templates para o projeto
mkdir -p infra/hetzner
cp -r "${CLAUDE_SKILL_DIR}/templates/." infra/hetzner/
ls infra/hetzner/
Passo 3 — Coletar parâmetros (um por vez)
Obrigatórios:
project_name— nome do projeto em kebab-case (ex:meuapp). Nomeia todos os recursos no Hetzner.hetzner_api_token— token de API Hetzner com permissão Read+Write.Console Hetzner > selecione o projeto > Security > API Tokens > Generate API Token Permissão: Read & Write — token mostrado uma única vez, copiar agora.
ssh_public_key— conteúdo da chave pública SSH (já detectado acima).- Se não existe:
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -C "deploy@hetzner"
- Se não existe:
ssh_allowed_ips— IP público atual já detectado acima. Confirmar com o usuário.- Se IP for dinâmico: avisar que precisará atualizar e rodar
terraform applyquando mudar. - Formato:
["1.2.3.4/32"]— múltiplos:["1.2.3.4/32", "5.6.7.8/32"]
- Se IP for dinâmico: avisar que precisará atualizar e rodar
admin_username— usuário não-root no servidor (default:deploy).ssh_mode— como quer acessar o servidor via SSH:[1] Público (padrão) — porta 22 aberta, restrita ao seu IP no firewall Hetzner [2] Tailscale — porta 22 fechada na internet, acesso só via VPN TailscaleSe escolher Tailscale, pedir o auth key:
- Acesse https://login.tailscale.com/admin/settings/keys
- Generate auth key com: Reusable + Pre-authorized + tag:server + expiry 1 dia
- Copie o
tskey-auth-...
Vantagem do Tailscale: funciona com IP dinâmico, times com múltiplos IPs, e fecha completamente a porta 22 na internet. Desvantagem: dependência do serviço Tailscale (se travado, usar o Console web da Hetzner).
Opcionais — mostrar defaults e perguntar se quer alterar:
| Parâmetro | Default | Opções |
|---|---|---|
server_type |
cpx21 (€7.49) |
cx23 2vCPU/4GB €3.49 · cax21 4vCPU/8GB ARM €9.49 |
location |
nbg1 Nuremberg |
hel1 Helsinki · fsn1 Falkenstein |
ssh_port |
22 |
outro número reduz ruído de bots (só no modo público) |
timezone |
UTC |
ex: America/Sao_Paulo |
Passo 4 — Gerar cloud-init.yaml
Invocar /linux-vm-hardening com todos os parâmetros coletados:
output_path:infra/hetzner/cloud-init.yamladmin_username,ssh_public_key,ssh_port,timezone,ssh_modetailscale_auth_keysessh_mode=tailscale
Aguardar a geração do arquivo antes de continuar.
Passo 5 — Criar terraform.tfvars
Criar infra/hetzner/terraform.tfvars com os valores coletados. Não exibir o token em output.
Passo 6 — Terraform init + plan
cd infra/hetzner && terraform init
cd infra/hetzner && terraform plan -out=tfplan
Resumir o plano: recursos criados, server_type, location. Se aparecer qualquer destroy, parar e pedir confirmação explícita com aviso de impacto.
Verificação obrigatória por ssh_mode
Antes de pedir confirmação, validar que o plano bate com o modo escolhido:
ssh_mode = tailscale — plano deve ter apenas:
- ✅ UDP 41641 — Tailscale WireGuard
- ✅ TCP 80 / 443 — Cloudflare
- ✅ ICMP
- ❌ TCP 22 — não deve aparecer
- ❌ TCP 8000 / 6001 / 6002 — não devem aparecer (Coolify via Tailscale IP)
ssh_mode = public — plano deve ter:
- ✅ TCP 22 restrito a
ssh_allowed_ips - ✅ TCP 8000 / 6001 / 6002 restrito a
ssh_allowed_ips - ✅ TCP 80 / 443 — Cloudflare
- ✅ ICMP
Se qualquer porta errada aparecer, parar, corrigir o tfvars e re-rodar o plan.
Passo 7 — Confirmação e apply
Pedir confirmação antes de aplicar. Após OK do usuário:
cd infra/hetzner && terraform apply tfplan
Passo 8 — Outputs e próximos passos
cd infra/hetzner && terraform output
Apresentar ao usuário — mensagem varia por ssh_mode:
Modo public:
SERVIDOR PROVISIONADO
=====================
IP público: [server_ip]
SSH: ssh [admin_username]@[server_ip] -p [ssh_port]
Coolify: http://[server_ip]:8000 (disponível em ~10 min)
O servidor ainda está instalando Docker e Coolify via cloud-init.
Para acompanhar: ssh [admin_username]@[server_ip] 'sudo cloud-init status --wait'
⚠️ ATENÇÃO — WIZARD COOLIFY (leia antes de abrir o browser):
O Coolify vai mostrar "Server is not reachable" ao escolher "Localhost" — isso é esperado.
A chave SSH que ele usa é gerada durante a instalação, não existe antes do cloud-init rodar.
Quando aparecer a tela com a chave pública, copie-a e rode:
ssh [admin_username]@[server_ip] -p [ssh_port] \
'sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh && \
echo "CHAVE_AQUI" | sudo tee -a /root/.ssh/authorized_keys && \
sudo chmod 600 /root/.ssh/authorized_keys'
Depois clique "Check Again" — deve conectar na primeira tentativa.
PRÓXIMOS PASSOS:
1. Acesse http://[server_ip]:8000 → crie conta admin
2. Setup wizard → "Choose Server" → "Localhost"
3. "Server is not reachable" → copie a chave exibida → rode o comando acima → Check Again
4. Configure DNS no Cloudflare:
coolify.seudominio.com → A → [server_ip] → Proxied (nuvem laranja)
SSL/TLS → Full (NAO "Full Strict" — causa erro 526 com o cert self-signed do Coolify)
O Cloudflare faz a terminacao SSL; o Coolify recebe HTTP interno — funciona com Full.
5. Coolify Settings > Instance > campo "URL" → https://coolify.seudominio.com → Save
6. Conecte GitHub → veja Passo 9
Modo tailscale:
SERVIDOR PROVISIONADO
=====================
IP público: [server_ip] (só para tráfego web — SSH/Coolify fechados)
IP Tailscale: aguardando... rode: tailscale status | grep [project_name]
SSH: ssh [admin_username]@<tailscale-ip>
Coolify: http://<tailscale-ip>:8000 (disponível em ~10 min, só via Tailscale)
O servidor ainda está instalando Docker, Coolify e Tailscale via cloud-init.
Para acompanhar: ssh [admin_username]@<tailscale-ip> 'sudo cloud-init status --wait'
⚠️ ATENÇÃO — WIZARD COOLIFY (leia antes de abrir o browser):
O Coolify vai mostrar "Server is not reachable" ao escolher "Localhost" — isso é esperado.
A chave SSH que ele usa é gerada durante a instalação, não existe antes do cloud-init rodar.
Quando aparecer a tela com a chave pública, copie-a e rode:
ssh [admin_username]@<tailscale-ip> \
'sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh && \
echo "CHAVE_AQUI" | sudo tee -a /root/.ssh/authorized_keys && \
sudo chmod 600 /root/.ssh/authorized_keys'
Depois clique "Check Again" — deve conectar na primeira tentativa.
PRÓXIMOS PASSOS:
1. Aguarde o IP Tailscale: tailscale status
2. Acesse http://<tailscale-ip>:8000 → crie conta admin
3. Setup wizard → "Choose Server" → "Localhost"
4. "Server is not reachable" → copie a chave exibida → rode o comando acima → Check Again
5. Configure DNS no Cloudflare:
coolify.seudominio.com → A → [server_ip] → Proxied (nuvem laranja)
SSL/TLS → Full (NAO "Full Strict" — causa erro 526 com o cert self-signed do Coolify)
O Cloudflare faz a terminacao SSL; o Coolify recebe HTTP interno — funciona com Full.
6. Coolify Settings > Instance > campo "URL" → https://coolify.seudominio.com → Save
7. Conecte GitHub → veja Passo 9
Passo 9 — GitHub (repos privados + webhook)
Perguntar: "Vai usar repos privados do GitHub?"
Se sim — explicar as duas opções e perguntar qual prefere:
Opção A — GitHub App (recomendado)
Coolify cria um GitHub App na sua conta. Você instala nos repos que quiser liberar. Webhook configurado automaticamente — cada push dispara deploy.
Coolify → Sources → Add → GitHub App → seguir o wizard
→ instalar o App nos repos desejados
→ Resources → New Resource → Public Repository (mesmo sendo privado, já está autorizado)
Pré-requisito: URL configurada no Coolify (Passo 8 item 5) com HTTPS funcionando. O callback OAuth do GitHub App precisa de HTTPS — não funciona via IP:8000.
Opção B — Deploy Key + Webhook manual
Coolify gera uma chave SSH de deploy. Você adiciona manualmente no repo.
1. Coolify → Resources → New → Private Repository (with deploy key)
Coolify exibe uma chave pública SSH — copie-a
2. GitHub → repo → Settings → Deploy keys → Add deploy key → cole a chave
3. Para webhook automático:
GitHub → repo → Settings → Webhooks → Add webhook
Payload URL: https://coolify.seudominio.com/webhooks/source/github
Content type: application/json
Events: Just the push event
Mais trabalhoso, mas funciona sem criar um GitHub App e sem OAuth.
Sobre firewall e webhooks
O webhook do GitHub chega via HTTPS (porta 443) → Cloudflare proxy → servidor. O firewall já abre 443 apenas via Cloudflare, então os webhooks passam normalmente.
Porta 8000 não recebe webhooks — o GitHub não consegue alcançá-la. URL configurada no Coolify (campo "URL" em Settings > Instance) é pré-requisito antes de qualquer integração GitHub.
Acesso ao painel Coolify com domínio (modo tailscale)
Com ssh_mode=tailscale, o firewall bloqueia 443 para IPs não-Cloudflare.
Isso significa que https://infra.seudominio.com não abre no browser via internet — só via Tailscale (:8000).
Este é o design correto: painel Coolify invisível na internet, webhooks chegam via Cloudflare em 443.
GitHub webhook → Cloudflare (443) → servidor ✅
Você (admin) → Tailscale → 100.x.x.x:8000 ✅
Internet aleatória → bloqueado pelo firewall ✅
Se precisar expor o painel publicamente (não recomendado), adicionar em terraform.tfvars:
coolify_allowed_ips = ["0.0.0.0/0"] # ou seu IP específico
e adicionar a regra no firewall.tf.
Troubleshooting
| Erro | Causa | Solução |
|---|---|---|
SSH key name already exists |
Chave já no projeto Hetzner | terraform import hcloud_ssh_key.main <KEY_ID> |
Invalid token |
API token errado ou expirado | Regerar no console Hetzner |
| SSH recusa após 10+ min | IP não está em ssh_allowed_ips |
curl ifconfig.me e atualizar tfvars |
| Coolify não abre na 8000 | cloud-init ainda rodando | ssh [admin_username]@IP 'sudo cloud-init status' |
Server is not reachable no wizard Coolify |
Chave SSH do Coolify não está em /root/.ssh/authorized_keys |
Copiar a chave exibida na tela e rodar: echo "CHAVE" | sudo tee -a /root/.ssh/authorized_keys → Check Again |
connect to host.docker.internal port 22: Operation timed out |
UFW bloqueia SSH do container Docker para o host — ufw allow não funciona para bridge Docker, precisa ir em before.rules |
sudo sed -i '/^# End required lines/a \\n# Allow Docker containers to reach host SSH\n-A ufw-before-input -i docker0 -p tcp --dport 22 -j ACCEPT\n-A ufw-before-input -i br-+ -p tcp --dport 22 -j ACCEPT' /etc/ufw/before.rules && sudo ufw reload |
Error acquiring state lock |
Apply anterior travado | cd infra/hetzner && terraform force-unlock LOCK_ID |
| Erro 526 no Cloudflare | SSL mode "Full Strict" com cert self-signed do Coolify | Trocar para modo "Full" no Cloudflare até o Let's Encrypt ser gerado |
| Dashboard Coolify abre mas sem HTTPS via Cloudflare | Porta 8000 não é suportada pelo proxy Cloudflare | Acesse via IP/Tailscale (:8000) e configure a URL em Settings > Instance |