Deploy Automático com GitHub Actions, Docker e VPS: Guia Completo de CI/CD


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 como prisma generate) acontece no estágio builder, antes da imagem de produção ser montada. Nunca coloque build steps na imagem final.
  • npm ci é preferível ao npm install em pipelines — garante instalação determinística a partir do package-lock.json.
  • Copiar package.json antes 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:

  • image aponta para o registry — o servidor nunca faz build, apenas pull.
  • env_file mantém segredos fora do repositório. O arquivo .env.production deve existir no servidor mas nunca ser commitado.
  • restart: unless-stopped garante que o container suba automaticamente após reboot do VPS.
  • A rede external: true permite 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 o GITHUB_TOKEN automá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ço app sem 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:

  1. Vá em GitHub → Settings → Developer settings → Personal access tokens
  2. Crie um token com escopo read:packages
  3. No servidor, armazene como secret REGISTRY_TOKEN no GitHub
# No servidor, o login fica assim:
echo "SEU_PAT_TOKEN" | docker login ghcr.io -u SEU_USUARIO --password-stdin

Resumo:

ContextoTokenMotivo
Job de build (Actions)GITHUB_TOKENTemporário, automático, suficiente para push
Servidor VPS (pull)PAT com read:packagesPrecisa persistir no servidor

6. Secrets Necessários no GitHub

Configure em Settings → Secrets and variables → Actions:

SecretDescriçãoComo obter
VPS_HOSTIP ou hostname do servidorPainel do seu provedor de VPS
VPS_USERUsuário SSH no servidorGeralmente root ou usuário criado
VPS_SSH_KEYChave privada SSH (conteúdo do ~/.ssh/id_ed25519)cat ~/.ssh/id_ed25519 na máquina local
VPS_PORTPorta SSH (padrão: 22)Verifique no /etc/ssh/sshd_config
REGISTRY_USERUsername do GitHubSeu login do GitHub
REGISTRY_TOKENPAT com read:packagesGitHub → 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 healthcheck no Dockerfile e configurar depends_on no 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 (branch main) 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