Toda vez que eu escrevia um artigo novo no blog, o fluxo era o mesmo: terminar o HTML, abrir o terminal, rodar o Ansible manualmente pra copiar os arquivos pro servidor. Funcionava. Mas era manual, chato, e já entendi que não é assim que funciona no mercado.
Decidi automatizar. O objetivo era simples: qualquer push na main do repositório do site deveria disparar o deploy automaticamente, sem eu precisar fazer nada. Este artigo documenta como cheguei lá, incluindo todos os erros no caminho.
# O que eu tinha antes
O servidor já estava configurado via Ansible: nginx rodando, Tailscale instalado, arquivos em /var/www/luisbrancher.dev/. O deploy manual era basicamente um git clone + chown que o próprio playbook resolvia.
O que eu queria era eliminar essa etapa manual. Push no repo → site atualizado. Sem abrir terminal, sem rodar Ansible.
# A escolha: GitHub Actions + rsync
O GitHub Actions é a ferramenta de CI/CD nativa do GitHub. Cada workflow é um arquivo YAML dentro de .github/workflows/ no próprio repositório. Quando o evento configurado acontece, no meu caso um push na main, o GitHub sobe um runner (uma VM Ubuntu), executa os steps definidos e destrói tudo no final.
Para o deploy em si, escolhi rsync via SSH. O Ansible já usava essa abordagem por baixo: rsync é a ferramenta certa pra sincronizar arquivos entre máquinas, transferindo só o que mudou.
O servidor fica acessível via Tailscale, então o runner também precisaria entrar na minha Tailnet antes de conseguir alcançar o servidor.
# Estrutura do workflow
Criei o arquivo .github/workflows/deploy.yml:
name: Deploy Site
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Connect on Tailscale
uses: tailscale/github-action@v2
with:
authkey: ${{ secrets.TS_AUTHKEY }}
args: --accept-routes
- name: Fix ownership before deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: sudo chown -R admin:admin /var/www/luisbrancher.dev/
- name: Deploy with rsync
uses: burnett01/[email protected]
with:
switches: -avz --delete --exclude='.git/'
path: ./
remote_path: /var/www/luisbrancher.dev/
remote_host: ${{ secrets.SERVER_HOST }}
remote_user: ${{ secrets.SERVER_USER }}
remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Fix ownership after deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: sudo chown -R www-data:www-data /var/www/luisbrancher.dev/
O fluxo em palavras:
- Runner sobe uma VM Ubuntu
- Faz checkout do repositório
- Entra na minha Tailnet com uma auth key efêmera
- Passa o dono dos arquivos no servidor pro
admin(pra permitir a escrita) - rsync copia os arquivos, excluindo a pasta
.git/ - Devolve o dono pro
www-data(pra o nginx conseguir ler) - Job termina → runner é destruído → sai automaticamente da Tailnet
Os secrets TS_AUTHKEY, SERVER_HOST, SERVER_USER e SSH_PRIVATE_KEY ficam em Settings → Secrets and variables → Actions no GitHub. Nada de credencial no código.
# Os erros que apareceram
Não funcionou na primeira tentativa. Nem na segunda. Cada erro ensinou alguma coisa.
Erro 1 — Permission denied (publickey)
Warning: Permanently added '***' (ED25519) to the list of known hosts.
***@***: Permission denied (publickey).
rsync: connection unexpectedly closed (0 bytes received so far) [sender]O Tailscale estava funcionando, o runner chegou no servidor e registrou o host. Mas o SSH rejeitou a conexão.
O problema estava na chave privada cadastrada no secret do GitHub. Chave ED25519 precisa da newline no final e se copiar sem ela, chega corrompida pro runner e o SSH rejeita silenciosamente. Recriei o secret copiando com cuidado do terminal.
Mesmo assim o erro persistiu. Porque a chave que eu estava usando possuía uma passphrase.
A solução definitiva foi criar um par dedicado pro CI/CD, sem passphrase (o runner não consegue digitar senha interativamente):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_ed25519_deployAdicionei a pública no servidor:
ssh -i ~/.ssh/id_ed25519 admin@<IP-tailscale> \
"echo '$(cat ~/.ssh/id_ed25519_deploy.pub)' >> ~/.ssh/authorized_keys"E cadastrei a privada no secret. Chave pessoal e chave de automação separadas, é a prática certa de qualquer forma.
Erro 2 — rsync: command not found
Identity added: (stdin) (github-actions-deploy)
Warning: Permanently added '***' (ED25519) to the list of known hosts.
bash: line 1: rsync: command not foundA chave estava funcionando agora. Mas o rsync não estava instalado no servidor. O Ansible nunca tinha precisado instalar porque usava o módulo ansible.builtin.git pra clonar o repositório, não rsync direto.
sudo apt install rsync -yErro 3 — Permission denied no rsync
rsync: [receiver] mkstemp "/var/www/luisbrancher.dev/.index.html..." failed: Permission denied (13)O rsync conectou, mas não conseguiu escrever os arquivos. O diretório /var/www/luisbrancher.dev/ pertencia ao www-data — o usuário do nginx. O admin não tinha permissão de escrita.
A solução foi adicionar dois steps de chown em volta do rsync: um antes pra passar o dono pro admin, um depois pra devolver pro www-data. O nginx continua lendo normalmente, e o rsync consegue escrever durante o deploy.
Erro 4 — .git/ sendo copiado pro servidor
No mesmo log do erro 3, dava pra ver a pasta .git/ sendo enviada pro servidor junto com os arquivos do site. Não faz sentido ter isso em /var/www/. O fix foi adicionar --exclude='.git/' nos switches do rsync.
# O que ficou
O workflow final resolve exatamente o que eu precisava. Push na main → site atualizado em segundos, sem intervenção manual.
O próximo passo natural é fechar a porta 22 publicamente no Security Group via Terraform, hoje ela ainda está aberta, uma dívida técnica documentada desde o projeto de IaC. Com o deploy passando pela Tailscale, não tem mais motivo pra manter SSH exposto.
E quando o homelab novo subir com ArgoCD, o ciclo fecha: GitHub Actions como CI no repositório do site, ArgoCD como CD no cluster. Mas isso é assunto pra outro post.
💡 O que aprendi
A maior lição não foi técnica, foi sobre troubleshooting. Cada erro no Actions aparece no log com detalhes suficientes pra entender o que aconteceu. O processo é: ler o erro, identificar a causa, corrigir, commitar, push, ver o próximo erro.
É assim que funciona na prática. É muito legal quando funciona de primeira, mas nem sempre é a realidade. O que importa é saber ler o log e não desistir no segundo erro.