CI/CD com Docker e GitHub Actions

Containerizar a aplicação e usar o Docker como artefato de deploy resolve o problema de "funciona na minha máquina", a imagem que passa no CI é exatamente a mesma que vai para produção. Este pipeline automatiza build, testes, push para o registry e rollout na VPS.

Dockerfile otimizado para produção

Multi-stage build para imagem mínima de produção:

dockerfile
# Dockerfile — multi-stage para Node.js:
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./

# Stage de dependências:
FROM base AS deps
RUN npm ci

# Stage de build:
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage de produção (apenas artefatos finais):
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

# Instalar apenas dependências de produção:
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copiar build do stage anterior:
COPY --from=build /app/dist ./dist

# Criar usuário não-root:
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/index.js"]

Pipeline: build, test e push para GHCR

GitHub Container Registry (GHCR), gratuito para repositórios públicos e privados:

yaml
# .github/workflows/ci.yml
name: CI — Build e Push Docker

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      # Cache de layers Docker (acelera builds repetidos):
      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: ${{ runner.os }}-buildx-

      - name: Configurar Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login no GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extrair metadata da imagem
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build e push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

      # Rotacionar cache (evita crescimento indefinido):
      - name: Mover cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Rodar testes dentro do container no CI

Garantir que os testes passam na mesma imagem que vai para produção:

yaml
# .github/workflows/ci.yml — adicionar job de testes:
  test:
    runs-on: ubuntu-latest
    services:
      # Subir PostgreSQL e Redis para testes de integração:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        options: --health-cmd "redis-cli ping"
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Rodar testes
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
          NODE_ENV: test
        run: npm test

      - name: Verificar TypeScript
        run: npx tsc --noEmit

Deploy na VPS com docker-compose pull

Atualizar o container na VPS após novo push para o registry:

yaml
# .github/workflows/deploy.yml — após build e push da imagem:
  deploy:
    needs: [test, build-and-push]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: GITHUB_SHA,GITHUB_ACTOR
          script: |
            set -e
            cd /opt/minha-api

            # Login no GHCR da VPS:
            echo ${{ secrets.GITHUB_TOKEN }} | \
              docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Atualizar imagem com o SHA do commit:
            export IMAGE_TAG=sha-${GITHUB_SHA::7}
            sed -i "s|image: ghcr.io/.*/minha-api:.*|image: ghcr.io/org/minha-api:${IMAGE_TAG}|" docker-compose.yml

            # Pull da nova imagem e restart:
            docker compose pull api
            docker compose up -d api --no-deps

            # Aguardar healthcheck passar:
            sleep 10
            docker compose ps api

            # Limpar imagens antigas:
            docker image prune -f

Variáveis de ambiente e secrets no docker-compose

Gerenciar configuração de produção no servidor:

yaml
# docker-compose.yml — na VPS de produção:
services:
  api:
    image: ghcr.io/minha-org/minha-api:latest  # tag atualizada pelo CI
    restart: always
    env_file:
      - .env.production  # arquivo gerenciado manualmente no servidor
    environment:
      # Variáveis que podem vir de env do host:
      - GIT_COMMIT=${GIT_COMMIT:-unknown}
    ports:
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"

# .env.production — no servidor, nunca commitado:
DATABASE_URL=postgresql://user:senha@localhost:5432/producao
REDIS_URL=redis://localhost:6379
JWT_SECRET=segredo-muito-longo-aqui
NODE_ENV=production

Não quer configurar manualmente?

Implante o VPS para Docker em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

GHCR (GitHub Container Registry): integrado ao GitHub, sem configuração extra de token (usa GITHUB_TOKEN), plano gratuito com 500 MB de storage, imagens privadas gratuitas para planos pagos do GitHub. Docker Hub Free: 1 repositório privado gratuito, limite de pulls anônimos (200/6h por IP). Para projetos no GitHub: GHCR é a escolha natural. Para multi-cloud ou GitLab: Docker Hub ou registry self-hosted.

Multi-stage separa o ambiente de build (Node.js completo, devDependencies, TypeScript, ferramentas) do artefato final (apenas o /dist e prodDependencies). Resultado: imagem de produção 3-10× menor (200 MB → 50 MB), menor superfície de ataque, menos vulnerabilidades para scanear, pull mais rápido na VPS. Sempre use multi-stage para aplicações TypeScript.

Cada build gera uma tag com o SHA do commit (sha-abc1234). Para rollback: no servidor, docker compose stop api && sed -i "s|:sha-.*|:sha-COMMIT-ANTERIOR|" docker-compose.yml && docker compose up -d api. Ou: manter um arquivo .env com IMAGE_TAG e atualizar apenas ela. Vantagem do Docker: o rollback é determinístico, a imagem antiga ainda está no registry.

Docker Compose para VPS única: simples, baixo overhead, fácil de depurar, sem aprendizado de Kubernetes. Kubernetes em VPS: overhead de ~2 GB RAM só para o control plane (k3s usa menos), complexidade operacional alta, faz sentido quando você precisa de múltiplos nós ou das abstrações do Kubernetes (HPA, operators). Para 95% dos casos de VPS single-node: Docker Compose é a resposta certa.

Os runners do GitHub Actions têm IPs que mudam, não é prático liberar por IP no UFW. Soluções: (1) manter porta SSH aberta para qualquer IP com autenticação por chave (seguro o suficiente com ed25519); (2) usar runner self-hosted na própria VPS ou rede privada, o runner inicia a conexão de saída para o GitHub, sem necessidade de porta aberta; (3) usar VPN ou Wireguard para acessar a VPS internamente.