Você já cansou de fazer deploy manual? Conectar no servidor, rodar git pull, rebuildar a imagem, rezar para não ter quebrado nada em produção? Neste guia você vai montar uma pipeline de CI/CD completa que faz tudo isso automaticamente a cada git push na branch principal.
A arquitetura é simples, robusta e 100% gratuita (dentro dos limites do GitHub Actions): ao fazer push no repositório, o GitHub builda a imagem Docker, envia para o GitHub Container Registry (ghcr.io) e faz deploy no seu VPS via SSH.
1. Visão Geral da Arquitetura
┌──────────────┐ push ┌─────────────────────────┐
│ Repositório │──────────────▶│ GitHub Actions │
│ GitHub │ │ │
└──────────────┘ │ Job 1: build-and-push │
│ • docker build │
│ • docker push → ghcr.io │
│ │
│ Job 2: deploy │
│ • SSH no VPS │
│ • docker pull │
│ • docker compose up -d │
└──────────┬──────────────┘
│ SSH
┌──────────▼──────────────┐
│ VPS Linux │
│ Docker + Compose │
│ Nginx (opcional) │
└─────────────────────────┘
▲
┌──────────┴──────────────┐
│ ghcr.io │
│ GitHub Container │
│ Registry │
└─────────────────────────┘
Componentes:
- GitHub Actions: orquestra o CI/CD — build, push e deploy
- GitHub Container Registry (ghcr.io): armazena as imagens Docker versionadas
- VPS Linux: onde a aplicação roda em produção com Docker Compose
- Docker Compose: gerencia o container da aplicação, variáveis de ambiente e rede
2. Dockerfile — Boas Práticas para Aplicações Node.js
O Dockerfile a seguir usa multi-stage build: o builder compila a aplicação e o estágio final copia apenas o necessário, resultando em uma imagem de produção menor e mais segura.
# ── Estágio 1: dependências e build ──────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar manifests antes do código (melhor aproveitamento de cache)
COPY package.json package-lock.json ./
# Instalar todas as deps (incluindo devDependencies para o build)
RUN npm ci
# Copiar o restante do código
COPY . .
# Gerar artefatos do ORM (se aplicável) e buildar a aplicação
# Ajuste este bloco conforme sua stack:
RUN npx prisma generate 2>/dev/null || true # opcional: só se usar Prisma
RUN npm run build
# ── Estágio 2: imagem de produção ────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Instalar apenas deps de produção
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copiar build gerado no estágio anterior
COPY --from=builder /app/.next ./.next # Next.js — ajuste se necessário
COPY --from=builder /app/public ./public # arquivos estáticos
COPY --from=builder /app/node_modules/.prisma \ # schema gerado do Prisma
./node_modules/.prisma 2>/dev/null || true
# Usuário não-root por segurança
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["npm", "start"]
Regras importantes:
- O
npm run build(e qualquer geração de artefatos comoprisma generate) acontece no estágiobuilder, antes da imagem de produção ser montada. Nunca coloque build steps na imagem final. npm cié preferível aonpm installem pipelines — garante instalação determinística a partir dopackage-lock.json.- Copiar
package.jsonantes do código fonte maximiza o cache de camadas do Docker. - Use um usuário não-root no estágio final para reduzir a superfície de ataque.
3. docker-compose.yml para Produção
No servidor, o Compose gerencia o container, as variáveis de ambiente e a rede compartilhada com outros serviços (ex.: Nginx reverse proxy).
# docker-compose.yml (no servidor VPS)
version: "3.9"
services:
app:
image: ghcr.io/SEU_USUARIO/SEU_REPOSITORIO:latest
restart: unless-stopped
env_file:
- .env.production # variáveis de ambiente sensíveis ficam aqui
ports:
- "3000:3000" # remova se usar Nginx como proxy reverso
networks:
- web # rede compartilhada com Nginx/Traefik
networks:
web:
external: true # rede já existente no host, criada com:
# docker network create web
Pontos de atenção:
imageaponta para o registry — o servidor nunca faz build, apenas pull.env_filemantém segredos fora do repositório. O arquivo.env.productiondeve existir no servidor mas nunca ser commitado.restart: unless-stoppedgarante que o container suba automaticamente após reboot do VPS.- A rede
external: truepermite que o Nginx (em outro Compose) roteie tráfego para a aplicação sem expor portas publicamente.
4. GitHub Actions Workflow
Crie o arquivo .github/workflows/deploy.yml no repositório:
name: Build and Deploy
on:
push:
branches:
- main # dispara apenas em push na branch principal
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} # formato: usuario/repositorio
jobs:
# ── Job 1: Build da imagem e push para ghcr.io ──────────────
build-and-push:
name: Build & Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # necessário para push no ghcr.io
steps:
- name: Checkout do código
uses: actions/checkout@v4
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login no GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # token automático do Actions
- name: Extrair metadados (tags e labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha- # tag com hash do commit
type=raw,value=latest # tag latest sempre atualizada
- name: Build e Push da imagem
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # cache entre execuções do Actions
cache-to: type=gha,mode=max
# ── Job 2: Deploy no servidor via SSH ───────────────────────
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: build-and-push # só executa após build bem-sucedido
environment: production # opcional: protege com aprovação manual
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
# Autenticar no registry (necessário no servidor)
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login ghcr.io -u ${{ secrets.REGISTRY_USER }} --password-stdin
# Navegar para o diretório do projeto
cd /opt/meu-projeto
# Baixar a nova imagem
docker compose pull app
# Restartar o serviço com zero-downtime
docker compose up -d --no-deps app
# Limpar imagens antigas (opcional)
docker image prune -f
O que cada job faz:
build-and-push: faz checkout do código, autentica no ghcr.io com oGITHUB_TOKENautomático, builda a imagem com cache e faz push com duas tags (SHA do commit +latest).deploy: conecta no VPS via SSH, autentica no registry (necessário para pull de imagens privadas), atualiza a imagem e reinicia apenas o serviçoappsem derrubar outros containers.
5. Autenticação no Registry
Este é um ponto que causa confusão. Existem dois contextos distintos:
No GitHub Actions (Job build-and-push)
Use GITHUB_TOKEN — um token temporário gerado automaticamente pelo Actions para cada execução. Não precisa de configuração manual. Funciona porque o job tem permissão packages: write no workflow.
password: ${{ secrets.GITHUB_TOKEN }} # ✅ automático, sem configuração
No servidor VPS (Job deploy)
O servidor não tem acesso ao GITHUB_TOKEN do Actions. Você precisa de um Personal Access Token (PAT) ou Fine-grained Token com permissão de leitura em packages:
- Vá em GitHub → Settings → Developer settings → Personal access tokens
- Crie um token com escopo
read:packages - No servidor, armazene como secret
REGISTRY_TOKENno GitHub
# No servidor, o login fica assim:
echo "SEU_PAT_TOKEN" | docker login ghcr.io -u SEU_USUARIO --password-stdin
Resumo:
| Contexto | Token | Motivo |
|---|---|---|
| Job de build (Actions) | GITHUB_TOKEN | Temporário, automático, suficiente para push |
| Servidor VPS (pull) | PAT com read:packages | Precisa persistir no servidor |
6. Secrets Necessários no GitHub
Configure em Settings → Secrets and variables → Actions:
| Secret | Descrição | Como obter |
|---|---|---|
VPS_HOST | IP ou hostname do servidor | Painel do seu provedor de VPS |
VPS_USER | Usuário SSH no servidor | Geralmente root ou usuário criado |
VPS_SSH_KEY | Chave privada SSH (conteúdo do ~/.ssh/id_ed25519) | cat ~/.ssh/id_ed25519 na máquina local |
VPS_PORT | Porta SSH (padrão: 22) | Verifique no /etc/ssh/sshd_config |
REGISTRY_USER | Username do GitHub | Seu login do GitHub |
REGISTRY_TOKEN | PAT com read:packages | GitHub → Settings → Developer settings → Tokens |
Como gerar o par de chaves SSH para CI/CD:
# Gerar chave dedicada para o deploy (não use sua chave pessoal)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_deploy -N ""
# Copiar a chave pública para o servidor
ssh-copy-id -i ~/.ssh/id_deploy.pub usuario@ip-do-servidor
# Conteúdo da chave PRIVADA vai para o secret VPS_SSH_KEY:
cat ~/.ssh/id_deploy
7. Problema Comum: SSH com IP Restrito
Muitos provedores de VPS (incluindo Hostinger, DigitalOcean com firewall, etc.) permitem restringir o acesso SSH por IP de origem. Isso é bom para segurança, mas os IPs dos runners do GitHub Actions mudam a cada execução.
Opção A: Não restringir SSH por IP (mais simples)
Mantenha o SSH acessível, mas reforce a segurança de outras formas: chaves ED25519 (sem senha), fail2ban instalado, porta SSH não padrão, PermitRootLogin no se possível.
Opção B: VPN com Tailscale (mais seguro)
Instale o Tailscale no VPS e na pipeline:
- name: Setup Tailscale
uses: tailscale/github-action@v2
with:
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
- name: Deploy via SSH (via Tailscale IP)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_TAILSCALE_IP }} # IP privado da rede Tailscale
# ... resto das configs
Opção C: Liberar IP temporariamente via API do provedor
Se o provedor tiver API, você pode adicionar o IP do runner no firewall antes do deploy e remover depois. A maioria dos provedores modernos oferece isso (Hetzner, DigitalOcean, Vultr).
- name: Obter IP do runner
id: ip
run: echo "ip=$(curl -s https://api.ipify.org)" >> $GITHUB_OUTPUT
- name: Liberar IP no firewall (exemplo conceitual)
run: |
curl -X POST https://api.meu-provedor.com/firewalls/rules \
-H "Authorization: Bearer ${{ secrets.PROVIDER_API_KEY }}" \
-d '{"source": "${{ steps.ip.outputs.ip }}", "port": 22}'
8. Troubleshooting — Erros Comuns
manifest unknown ou image not found no docker compose pull
O servidor não consegue encontrar a imagem no registry. Causas comuns:
# 1. Verificar se o login funcionou:
docker login ghcr.io -u SEU_USUARIO
# 2. Verificar se o nome da imagem no compose bate exatamente com o registry:
# ghcr.io/usuario/repositorio:latest (tudo minúsculo!)
# 3. Verificar se o token tem permissão read:packages
Build funciona mas container não atualiza
O Compose está usando a imagem em cache local. Use sempre pull antes de up:
docker compose pull app && docker compose up -d --no-deps app
docker compose vs docker-compose (versão obsoleta)
O comando docker-compose (com hífen) é a versão antiga (v1, deprecated). Use:
docker compose version # deve retornar Docker Compose version v2.x
Se o servidor ainda usa docker-compose, instale o plugin v2:
sudo apt-get install docker-compose-plugin
stderr do appleboy/ssh-action mas exit code 0
O ssh-action reporta erro mesmo quando o stderr contém apenas avisos. Adicione || true em comandos que podem gerar warnings não-fatais:
docker image prune -f || true
Para ver o output completo do SSH no log do Actions, adicione debug: true temporariamente:
- uses: appleboy/ssh-action@v1.0.3
with:
debug: true
# ...
Container sobe mas aplicação não responde
Verifique se o .env.production existe no servidor e contém todas as variáveis necessárias:
cd /opt/meu-projeto
docker compose logs app --tail=50
Resumo do Fluxo Completo
1. Developer faz push na branch `main`
│
▼
2. GitHub Actions dispara o workflow `deploy.yml`
│
▼
3. Job `build-and-push`:
• Autentica no ghcr.io com GITHUB_TOKEN (automático)
• Executa `docker build` com cache de camadas
• Faz push da imagem como:
- ghcr.io/usuario/repo:sha-abc1234
- ghcr.io/usuario/repo:latest
│
▼
4. Job `deploy` (após sucesso do build):
• Abre conexão SSH para o VPS
• Autentica no ghcr.io com PAT (REGISTRY_TOKEN)
• `docker compose pull app` → baixa a nova imagem
• `docker compose up -d --no-deps app` → sobe o container
• `docker image prune -f` → limpa imagens antigas
│
▼
5. Aplicação atualizada rodando em produção ✅
Próximos Passos
Com essa pipeline funcionando, os próximos investimentos naturais são:
- Notificações: enviar alertas no Slack ou Discord ao final do deploy (Actions tem action nativa para isso)
- Rollback automático: usar tags com hash do commit (
sha-abc1234) e manter as 3 últimas imagens para rollback rápido - Health check: adicionar
healthcheckno Dockerfile e configurardepends_onno Compose para garantir que o container está saudável antes de receber tráfego - Ambientes múltiplos: adaptar o workflow para deploy em staging (branch
develop) e produção (branchmain) com aprovação manual
A fundação que você montou aqui — imagem versionada no registry, deploy via SSH, variáveis de ambiente gerenciadas por arquivo — é reutilizável para qualquer stack que rode em container, não apenas Next.js.
Precisa de Ajuda ou Quer Ir Além?
Montar a primeira pipeline é só o começo. À medida que sua infraestrutura cresce — múltiplos serviços, ambientes de staging, observabilidade, controle de custos em cloud — a complexidade operacional cresce junto.
Se você precisa de apoio para:
- Manter e evoluir pipelines de CI/CD já existentes
- Estruturar uma nova infraestrutura de containers em produção
- Implementar observabilidade (logs, métricas, traces) para enxergar o que acontece depois do deploy
- Otimizar custos de cloud com práticas de FinOps
- Contar com um time de SRE para garantir disponibilidade e performance
A DeltaOps é especializada exatamente nisso — DevOps, Cloud, Observabilidade e SRE para empresas que precisam de infraestrutura confiável sem montar um time interno do zero.
Entre em contato: deltaops.com.br | São Paulo, Brasil