GitHub Actions: três jeitos diferentes de fazer deploy numa VPS
Três aplicações, três workflows de deploy diferentes — e a escolha de onde rodar o build (no runner ou na VPS) muda tudo sobre o que cada um faz.
Fui automatizando deploy conforme os projetos foram pedindo, e acabei com três workflows de GitHub Actions que resolvem o mesmo problema — colocar código em produção numa VPS — de três jeitos diferentes. Nenhum foi planejado como “a forma certa” desde o início; cada um nasceu do tipo de aplicação que estava sendo deployada.
Landing page: copiar arquivo estático
O caso mais simples. Uma landing page sem processo de build — HTML, CSS e assets prontos no repositório. O workflow só precisa levar esses arquivos pra VPS:
./.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SCP
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: "index.html,404.html,sitemap.xml,robots.txt,public"
target: "/home/deploy/apps/landing-page"
appleboy/scp-action faz o checkout, pega os arquivos listados em source e copia via SCP pro target na VPS. Não tem build, não tem npm install, não tem nada rodando no servidor depois — é literalmente copiar arquivo de um lugar pro outro. Faz sentido porque não existe nada pra compilar: o HTML que está no repositório é o HTML que vai servir em produção.
API: git pull e rebuild direto na VPS
Pra API do Dauth o workflow já é outra categoria de coisa. Em vez de mandar arquivos, ele manda comandos — a VPS é quem faz o trabalho:
./.github/workflows/deploy.yml
name: Deploy VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy na VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /home/deploy/apps/api
git pull origin main
docker-compose down
docker-compose up -d --build
Aqui o appleboy/ssh-action abre uma sessão SSH e executa o script diretamente na VPS. Não existe checkout no runner do GitHub Actions — o repositório já está clonado na VPS, e o workflow só dá um git pull nele. O docker-compose down seguido de up -d --build derruba os containers, reconstrói a imagem com o código novo e sobe tudo de novo.
A diferença central pro caso da landing page: aqui é a VPS que builda, não o runner do GitHub. Faz sentido porque a imagem Docker da API precisa existir no ambiente onde ela vai rodar — não tem por que gerar essa imagem em outro lugar e depois transportar ela inteira pela rede.
Front React: build no runner, só o dist viaja
O terceiro caso mistura os dois anteriores. O React precisa de build (diferente da landing), mas eu não quero rodar npm run build na VPS (diferente da API):
./.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Deploy via SCP
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: "dist/"
target: "/home/deploy/apps/front/dist"
strip_components: 1
O npm ci e o npm run build rodam no runner do GitHub Actions — não na VPS. Só depois que o dist/ já existe, pronto e buildado, é que ele viaja via SCP pro servidor. A VPS nunca vê o código-fonte do React, nunca instala uma node_modules, nunca roda um builder: ela só recebe HTML/JS/CSS já compilados e serve isso (via Nginx, no meu caso).
O strip_components: 1 existe porque sem ele o SCP recriaria a pasta dist/ dentro do target, resultando em .../dist/dist/index.html. Com strip_components: 1, o conteúdo de dentro de dist/ vai direto pro target.
Por que a mesma decisão não serve pros três
O padrão que emergiu, sem eu ter desenhado de propósito, foi: builda onde o resultado do build é mais barato de mover.
- Landing: não tem build, então não tem escolha — só copia.
- API: a imagem Docker é pesada e específica do ambiente Linux/container da VPS. Buildar lá evita ter que empacotar e transportar uma imagem inteira pela rede a cada deploy.
- React: o
dist/de uma SPA é leve (poucos KBs a poucos MBs), e o processo de build (npm ci+ Vite/Webpack) é bem mais rápido e confiável rodando no runner do GitHub, com cache de dependências (cache: 'npm'), do que dependendo dos recursos da VPS pra fazer a mesma coisa.
Ou seja, a pergunta que decide onde buildar não é “qual é mais simples”, é “o que é mais barato de mover: o código-fonte ou o artefato final?”.
Limitações que ficaram evidentes
- Nenhum dos três workflows tem rollback automático. Se o
docker-compose up -d --buildda API subir uma versão quebrada, o jeito de reverter é manual — outrogit pullpra um commit anterior e rebuild. - Não existe healthcheck pós-deploy em nenhum dos três. O workflow termina “com sucesso” assim que o
docker-compose upou oscpretornam sem erro, mas isso não garante que a aplicação realmente subiu saudável. - Zero downtime não é garantido na API: entre o
docker-compose downe oup -d --buildterminar de subir, a API fica fora do ar. Pra um serviço com usuários ativos, isso é uma janela de indisponibilidade real a cada deploy. - Os três dependem da mesma chave SSH (
VPS_SSH_KEY) com acesso amplo à VPS. Não há segregação de permissões por projeto — qualquer um desses workflows, se comprometido, tem o mesmo nível de acesso ao servidor.
Próximo passo
O ponto mais urgente de resolver é o downtime da API: trocar o docker-compose down + up --build por um rebuild que sobe o container novo antes de derrubar o antigo (ou migrar pra alguma estratégia de blue-green mais séria). Depois disso, valeria adicionar um step de healthcheck no fim de cada workflow — um curl simples num endpoint de status já preveniria a maioria dos “deploy que passou mas a aplicação não subiu”.