Ir para o conteúdo
·32 min de leitura

GitHub Actions CI/CD: Deploys Zero-Downtime Que Realmente Funcionam

Meu setup completo de GitHub Actions: jobs de teste paralelos, cache de build Docker, deploy SSH para VPS, zero-downtime com PM2 reload, gestão de secrets e os padrões de workflow refinados em dois anos.

Compartilhar:X / TwitterLinkedIn

Todo projeto em que trabalhei eventualmente chega ao mesmo ponto de inflexão: o processo de deploy fica doloroso demais para fazer manualmente. Você esquece de rodar os testes. Faz o build localmente mas esquece de atualizar a versão. Faz SSH no servidor de produção e descobre que a última pessoa que fez deploy deixou um arquivo .env desatualizado.

GitHub Actions resolveu isso para mim há dois anos. Não perfeitamente no primeiro dia — o primeiro workflow que escrevi era um pesadelo YAML de 200 linhas que dava timeout metade das vezes e não fazia cache de nada. Mas iteração por iteração, cheguei a algo que faz deploy deste site de forma confiável, com zero downtime, em menos de quatro minutos.

Este é aquele workflow, explicado seção por seção. Não a versão da documentação. A versão que sobrevive ao contato com produção.

Entendendo os Blocos Fundamentais#

Antes de entrarmos no pipeline completo, você precisa de um modelo mental claro de como o GitHub Actions funciona. Se você usou Jenkins ou CircleCI, esqueça a maior parte do que sabe. Os conceitos se mapeiam vagamente, mas o modelo de execução é diferente o suficiente para confundir.

Triggers: Quando Seu Workflow Executa#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Toda segunda-feira às 6h UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Quatro triggers, cada um servindo a um propósito diferente:

  • push para main é o trigger de deploy em produção. Código mergeado? Manda.
  • pull_request roda suas verificações de CI em cada PR. É aqui que lint, verificações de tipo e testes vivem.
  • schedule é o cron do seu repositório. Eu uso para scans semanais de auditoria de dependências e limpeza de cache desatualizado.
  • workflow_dispatch dá a você um botão manual "Deploy" na interface do GitHub com parâmetros de entrada. Inestimável quando você precisa fazer deploy em staging sem uma mudança de código — talvez você tenha atualizado uma variável de ambiente ou precise fazer pull de uma imagem Docker base novamente.

Um detalhe que pega as pessoas: pull_request roda contra o merge commit, não contra o HEAD do branch do PR. Isso significa que seu CI está testando como o código ficará após o merge. Na verdade é isso que você quer, mas surpreende as pessoas quando um branch verde fica vermelho após um rebase.

Jobs, Steps e Runners#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Jobs rodam em paralelo por padrão. Cada job recebe uma VM nova (o "runner"). ubuntu-latest dá a você uma máquina razoavelmente potente — 4 vCPUs, 16 GB de RAM em 2026. Isso é gratuito para repositórios públicos, 2000 minutos/mês para privados.

Steps rodam sequencialmente dentro de um job. Cada step uses: puxa uma action reutilizável do marketplace. Cada step run: executa um comando shell.

A flag --frozen-lockfile é crucial. Sem ela, pnpm install pode atualizar seu lockfile durante o CI, o que significa que você não está testando as mesmas dependências que o desenvolvedor commitou. Já vi isso causar falhas fantasma em testes que desaparecem localmente porque o lockfile na máquina do desenvolvedor já está correto.

Variáveis de Ambiente vs Secrets#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

Variáveis de ambiente definidas com env: no nível do workflow são texto puro, visíveis nos logs. Use para configurações não sensíveis: NODE_ENV, flags de telemetria, feature toggles.

Secrets (${{ secrets.X }}) são criptografados em repouso, mascarados nos logs e disponíveis apenas para workflows no mesmo repositório. São definidos em Settings > Secrets and variables > Actions.

A linha environment: production é significativa. GitHub Environments permitem escopar secrets para alvos de deploy específicos. Sua chave SSH de staging e sua chave SSH de produção podem ambas se chamar SSH_PRIVATE_KEY mas ter valores diferentes dependendo de qual ambiente o job está direcionado. Isso também desbloqueia revisores obrigatórios — você pode exigir aprovação manual antes de deploys em produção.

O Pipeline CI Completo#

Veja como eu estruturo a metade CI do pipeline. O objetivo: capturar toda categoria de erro no menor tempo possível.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Por Que Essa Estrutura#

Lint, typecheck e test rodam em paralelo. Eles não têm dependências entre si. Um erro de tipo não bloqueia o lint de rodar, e um teste falhando não precisa esperar pelo verificador de tipos. Em uma execução típica, os três completam em 30-60 segundos rodando simultaneamente.

Build espera os três. A linha needs: [lint, typecheck, test] significa que o job de build só inicia se lint, typecheck E test todos passarem. Não faz sentido fazer build de um projeto que tem erros de lint ou falhas de tipo.

concurrency com cancel-in-progress: true economiza muito tempo. Se você faz push de dois commits em rápida sucessão, a primeira execução de CI é cancelada. Sem isso, você terá execuções desatualizadas consumindo seu orçamento de minutos e poluindo a interface de verificações.

Upload de cobertura com if: always() significa que você obtém o relatório de cobertura mesmo quando testes falham. Isso é útil para debugging — você pode ver quais testes falharam e o que eles cobriam.

Fail-Fast vs. Deixar Todos Rodarem#

Por padrão, se um job em uma matrix falha, o GitHub cancela os outros. Para CI, eu realmente quero esse comportamento — se o lint falha, não me importo com os resultados dos testes. Corrija o lint primeiro.

Mas para matrizes de teste (digamos, testando no Node 20 e Node 22), você pode querer ver todas as falhas de uma vez:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false permite que ambas as pernas da matrix completem. Se o Node 22 falha mas o Node 20 passa, você vê essa informação imediatamente em vez de precisar re-executar.

Cache para Velocidade#

A maior melhoria que você pode fazer na velocidade do CI é caching. Um pnpm install sem cache em um projeto médio leva 30-45 segundos. Com cache quente, leva 3-5 segundos. Multiplique isso por quatro jobs paralelos e você está economizando dois minutos em cada execução.

Cache do Store pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Essa linha única faz cache do store do pnpm (~/.local/share/pnpm/store). Com cache hit, pnpm install --frozen-lockfile apenas faz hard-link do store ao invés de baixar. Isso sozinho reduz o tempo de instalação em 80% nas execuções seguintes.

Se você precisa de mais controle — digamos, quer fazer cache baseado no SO também — use actions/cache diretamente:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

O fallback restore-keys é importante. Se o pnpm-lock.yaml muda (nova dependência), a chave exata não vai bater, mas o prefixo vai restaurar a maioria dos pacotes em cache. Apenas o diff é baixado.

Cache de Build do Next.js#

Next.js tem seu próprio cache de build em .next/cache. Fazer cache disso entre execuções significa builds incrementais — apenas páginas e componentes alterados são recompilados.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Essa estratégia de chave em três níveis significa:

  1. Match exato: mesmas dependências E mesmos arquivos fonte. Cache hit completo, build quase instantâneo.
  2. Match parcial (dependências): dependências iguais mas código mudou. Build recompila apenas arquivos alterados.
  3. Match parcial (apenas SO): dependências mudaram. Build reutiliza o que pode.

Números reais do meu projeto: build sem cache leva ~55 segundos, build com cache leva ~15 segundos. Isso é uma redução de 73%.

Cache de Layers Docker#

Builds Docker são onde o caching realmente faz impacto. Um build Docker completo de Next.js — instalando dependências do SO, copiando código, rodando pnpm install, rodando next build — leva 3-4 minutos sem cache. Com cache de layers, são 30-60 segundos.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha usa o backend de cache nativo do GitHub Actions. mode=max faz cache de todas as layers, não apenas das finais. Isso é crítico para builds multi-stage onde layers intermediárias (como pnpm install) são as mais caras de reconstruir.

Cache Remoto do Turborepo#

Se você está em um monorepo com Turborepo, cache remoto é transformador. O primeiro build faz upload dos outputs das tasks para o cache. Builds subsequentes baixam ao invés de recomputar.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Já vi tempos de CI de monorepo cair de 8 minutos para 90 segundos com cache remoto do Turbo. O porém: requer uma conta Vercel ou servidor Turbo auto-hospedado. Para repositórios de app único, é exagero.

Build e Push Docker#

Se você está fazendo deploy para um VPS (ou qualquer servidor), Docker dá builds reproduzíveis. A mesma imagem que roda no CI é a mesma imagem que roda em produção. Chega de "funciona na minha máquina" porque a máquina é a imagem.

Dockerfile Multi-Stage#

Antes de chegarmos ao workflow, aqui está o Dockerfile que uso para Next.js:

dockerfile
# Estágio 1: Dependências
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Estágio 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Estágio 3: Produção
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Três estágios, separação clara. A imagem final tem ~150MB em vez dos ~1.2GB que você teria copiando tudo. Apenas artefatos de produção chegam ao estágio runner.

O Workflow de Build-and-Push#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Vou detalhar as decisões importantes aqui.

GitHub Container Registry (ghcr.io)#

Eu uso ghcr.io em vez do Docker Hub por três razões:

  1. Autenticação é gratuita. GITHUB_TOKEN está automaticamente disponível em todo workflow — não precisa guardar credenciais do Docker Hub.
  2. Proximidade. Imagens são puxadas da mesma infraestrutura onde seu CI roda. Pulls durante o CI são rápidos.
  3. Visibilidade. Imagens são vinculadas ao seu repositório na interface do GitHub. Você as vê na aba Packages.

Builds Multi-Plataforma#

yaml
platforms: linux/amd64,linux/arm64

Essa linha adiciona talvez 90 segundos ao seu build, mas vale a pena. Imagens ARM64 rodam nativamente em:

  • Macs com Apple Silicon (M1/M2/M3/M4) durante desenvolvimento local com Docker Desktop
  • Instâncias AWS Graviton (20-40% mais baratas que equivalentes x86)
  • Tier gratuito ARM da Oracle Cloud

Sem isso, seus desenvolvedores em Macs M-series estão rodando imagens x86 através de emulação Rosetta. Funciona, mas é notavelmente mais lento e ocasionalmente revela bugs específicos de arquitetura.

QEMU fornece a camada de compilação cruzada. Buildx orquestra o build multi-arch e faz push de uma lista de manifesto para que o Docker automaticamente puxe a arquitetura correta.

Estratégia de Tags#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Toda imagem recebe três tags:

  • abc1234 (SHA do commit): Imutável. Você pode sempre fazer deploy de um commit exato.
  • main (nome do branch): Mutável. Aponta para o último build daquele branch.
  • latest: Mutável. Definida apenas no branch padrão. É o que seu servidor puxa.

Nunca faça deploy de latest em produção sem também registrar o SHA em algum lugar. Quando algo quebra, você precisa saber qual latest. Eu guardo o SHA deployado em um arquivo no servidor que o endpoint de health lê.

Deploy SSH para VPS#

É aqui que tudo se junta. CI passa, imagem Docker é construída e enviada, agora precisamos dizer ao servidor para puxar a nova imagem e reiniciar.

A SSH Action#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Puxar a imagem mais recente
          docker pull "$IMAGE"
 
          # Parar e remover container antigo
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Iniciar novo container
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Aguardar health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Registrar SHA deployado
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Limpar imagens antigas
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

A Alternativa com Script de Deploy#

Para qualquer coisa além de um simples pull-e-restart, eu movo a lógica para um script no servidor em vez de inline no workflow:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Login no GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull com retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Função de health check
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Iniciar novo container na porta alternativa
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Verificar se o novo container está saudável
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Trocar upstream do Nginx
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Parar container antigo
docker stop akousa-app || true
docker rm akousa-app || true
 
# Renomear novo container
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

O workflow então se torna um único comando SSH:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Isso é melhor porque: (1) a lógica de deploy é versionada no servidor, (2) você pode rodá-la manualmente via SSH para debugging, e (3) você não precisa escapar YAML dentro de YAML dentro de bash.

Estratégias Zero-Downtime#

"Zero downtime" parece papo de marketing, mas tem um significado preciso: nenhuma requisição recebe um connection refused ou um 502 durante o deploy. Aqui estão três abordagens reais, da mais simples à mais robusta.

Estratégia 1: PM2 Cluster Mode Reload#

Se você está rodando Node.js diretamente (não em Docker), o cluster mode do PM2 dá o caminho mais fácil para zero-downtime.

bash
# ecosystem.config.js já tem:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (não restart) faz um rolling restart. Ele sobe novos workers, espera eles ficarem prontos, depois mata os workers antigos um por um. Em nenhum momento há zero workers servindo tráfego.

A flag --update-env recarrega as variáveis de ambiente do config do ecossistema. Sem ela, seu env antigo persiste mesmo após um deploy que mudou o .env.

No seu workflow:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

É isso que uso neste site. É simples, confiável, e o downtime é literalmente zero — testei com um gerador de carga rodando 100 req/s durante deploys. Nenhum 5xx sequer.

Estratégia 2: Blue/Green com Nginx Upstream#

Para deploys Docker, blue/green dá uma separação limpa entre a versão antiga e a nova.

O conceito: rode o container antigo ("blue") na porta 3000 e o novo container ("green") na porta 3001. O Nginx aponta para blue. Você inicia green, verifica que está saudável, muda o Nginx para green, depois para blue.

Configuração upstream do Nginx:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

O script de troca:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Iniciar novo container na porta alternativa
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Aguardar health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Trocar Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Parar container antigo
sleep 5  # Deixar requisições em andamento completarem
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

O sleep de 5 segundos após o reload do Nginx não é preguiça — é tempo de graça. O reload do Nginx é gracioso (conexões existentes são mantidas abertas), mas algumas conexões long-polling ou respostas streaming precisam de tempo para completar.

Estratégia 3: Docker Compose com Health Checks#

Para uma abordagem mais estruturada, Docker Compose pode gerenciar a troca blue/green:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

A linha order: start-first é a chave. Ela significa "inicie o novo container antes de parar o antigo." Combinada com parallelism: 1, você obtém uma atualização rolling — um container por vez, sempre mantendo capacidade.

Deploy com:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose monitora o healthcheck e não roteia tráfego para o novo container até que passe. Se o healthcheck falha, failure_action: rollback automaticamente reverte para a versão anterior. Isso é o mais próximo de deploys rolling estilo Kubernetes que você consegue em um único VPS.

Gestão de Secrets#

Gestão de secrets é uma daquelas coisas fáceis de acertar "na maioria" e catastroficamente errar nos casos restantes.

GitHub Secrets: O Básico#

yaml
# Definido via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # O valor é mascarado nos logs
      echo "Connecting to database..."
      # Isso imprimiria "Connecting to ***" nos logs
      echo "Connecting to $DB_URL"

O GitHub automaticamente redige valores de secrets da saída dos logs. Se seu secret é p@ssw0rd123 e qualquer step imprime essa string, os logs mostram ***. Funciona bem, com uma ressalva: se seu secret é curto (como um PIN de 4 dígitos), o GitHub pode não mascarar porque poderia bater com strings inocentes. Mantenha secrets razoavelmente complexos.

Secrets com Escopo de Ambiente#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Mesmo nome de secret, valores diferentes por ambiente. O campo environment no job determina qual conjunto de secrets é injetado.

Ambientes de produção devem ter revisores obrigatórios habilitados. Isso significa que um push para main dispara o workflow, CI roda automaticamente, mas o job de deploy pausa e espera alguém clicar "Approve" na interface do GitHub. Para um projeto solo, pode parecer overhead. Para qualquer coisa com usuários, é uma salvação na primeira vez que você acidentalmente mergeia algo quebrado.

OIDC: Chega de Credenciais Estáticas#

Credenciais estáticas (chaves de acesso AWS, JSONs de service account GCP) armazenadas no GitHub Secrets são um risco. Elas não expiram, não podem ser escopadas a uma execução específica de workflow, e se vazam, você precisa rotacioná-las manualmente.

OIDC (OpenID Connect) resolve isso. GitHub Actions atua como um provedor de identidade, e seu provedor cloud confia nele para emitir credenciais de curta duração sob demanda:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Necessário para OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Sem access key. Sem secret key. A action configure-aws-credentials solicita um token temporário do AWS STS usando o token OIDC do GitHub. O token é escopado ao repositório específico, branch e ambiente. Ele expira após a execução do workflow.

Configurar isso no lado da AWS requer um provedor de identidade OIDC no IAM e uma política de confiança de role:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

A condição sub é crucial. Sem ela, qualquer repositório que de alguma forma obtenha os detalhes do seu provedor OIDC poderia assumir a role. Com ela, apenas o branch main do seu repositório específico pode.

GCP tem uma configuração equivalente com Workload Identity Federation. Azure tem credenciais federadas. Se seu cloud suporta OIDC, use. Não há razão para armazenar credenciais cloud estáticas em 2026.

Chaves SSH para Deploy#

Para deploys VPS via SSH, gere um par de chaves dedicado:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Adicione a chave pública ao ~/.ssh/authorized_keys do servidor com restrições:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

O prefixo restrict desabilita port forwarding, agent forwarding, alocação de PTY e X11 forwarding. O prefixo command= significa que essa chave pode apenas executar o script de deploy. Mesmo que a chave privada seja comprometida, o atacante pode rodar seu script de deploy e nada mais.

Adicione a chave privada ao GitHub Secrets como SSH_PRIVATE_KEY. Essa é a única credencial estática que aceito — chaves SSH com comandos forçados têm um raio de explosão muito limitado.

Workflows de PR: Deploys de Preview#

Todo PR merece um ambiente de preview. Ele captura bugs visuais que testes unitários não pegam, permite que designers revisem sem fazer checkout do código, e torna a vida do QA dramaticamente mais fácil.

Deploy de Preview ao Abrir PR#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Encontrar comentário existente
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

O cálculo de porta (4000 + PR_NUM) é um hack pragmático. PR #42 recebe porta 4042. Contanto que você não tenha mais de algumas centenas de PRs abertos, não há colisões. Uma configuração wildcard do Nginx roteia pr-*.preview.akousa.net para a porta correta.

Limpeza ao Fechar PR#

Ambientes de preview que não são limpos consomem disco e memória. Adicione um job de limpeza:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Verificações de Status Obrigatórias#

Nas configurações do seu repositório (Settings > Branches > Branch protection rules), exija estas verificações antes do merge:

  • lint — Sem erros de lint
  • typecheck — Sem erros de tipo
  • test — Todos os testes passando
  • build — Projeto faz build com sucesso

Sem isso, alguém vai mergear um PR com verificações falhando. Não maliciosamente — vai ver "2 de 4 verificações passaram" e assumir que as outras duas ainda estão rodando. Trave isso.

Também habilite "Require branches to be up to date before merging." Isso força uma re-execução do CI após rebase no main mais recente. Captura o caso onde dois PRs individualmente passam o CI mas conflitam quando combinados.

Notificações#

Um deploy que ninguém sabe é um deploy em que ninguém confia. Notificações fecham o loop de feedback.

Webhook Slack#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

O if: always() é crítico. Sem ele, o step de notificação é pulado quando o deploy falha — que é exatamente quando você mais precisa.

API de Deployments do GitHub#

Para rastreamento de deploy mais rico, use a API de Deployments do GitHub. Isso dá um histórico de deploy na interface do repositório e habilita badges de status:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... steps reais de deploy ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Agora sua aba Environments no GitHub mostra um histórico completo de deploy: quem fez deploy do quê, quando, e se foi bem-sucedido.

Email Apenas em Falha#

Para deploys críticos, também disparo um email em falha. Não via email nativo do GitHub Actions (barulhento demais), mas via um webhook direcionado:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Essa é minha última linha de defesa. Slack é ótimo mas também é barulhento — as pessoas silenciam canais. Um email "DEPLOY FAILED" com link para a execução chama atenção.

O Arquivo de Workflow Completo#

Aqui está tudo conectado em um único workflow pronto para produção. Isso é muito próximo do que realmente faz deploy deste site.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, verificação de tipo e testes em paralelo
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Somente após CI passar
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: Build e push da imagem (apenas branch main)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH no VPS e atualizar
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Puxar nova imagem
            docker pull "$IMAGE"
 
            # Rodar novo container na porta alternativa
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Trocar tráfego
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Período de graça para requisições em andamento
            sleep 5
 
            # Parar container antigo
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Renomear e resetar porta
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Nota: não recarregamos o Nginx aqui porque o nome do container mudou,
            # não a porta. O próximo deploy usará a porta correta.
 
            # Registrar deploy
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Limpar imagens antigas (mais de 7 dias)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Acompanhando o Fluxo#

Quando eu faço push para main:

  1. Lint, Type Check e Test iniciam simultaneamente. Três runners, três jobs paralelos. Se qualquer um falha, o pipeline para.
  2. Build roda apenas se os três passarem. Valida que a aplicação compila e produz output funcional.
  3. Docker constrói a imagem de produção e faz push para ghcr.io. Multi-plataforma, com cache de layers.
  4. Deploy faz SSH no VPS, puxa a nova imagem, inicia um novo container, faz health-check, troca o Nginx e faz limpeza.
  5. Notificações disparam independente do resultado. Slack recebe a mensagem. GitHub Deployments é atualizado. Se falhou, um email de alerta é enviado.

Quando eu abro um PR:

  1. Lint, Type Check e Test rodam. Mesmas barreiras de qualidade.
  2. Build roda para verificar que o projeto compila.
  3. Docker e Deploy são pulados (as condições if limitam ao branch main apenas).

Quando preciso de um deploy emergencial (pular testes):

  1. Clico em "Run workflow" na aba Actions.
  2. Seleciono skip_tests: true.
  3. Lint e typecheck ainda rodam (não posso pular esses — não confio em mim mesmo tanto assim).
  4. Testes são pulados, build roda, Docker constrói, deploy dispara.

Esse tem sido meu workflow por dois anos. Ele sobreviveu a migrações de servidor, upgrades de versão major do Node.js, pnpm substituindo npm, e a adição de 15 ferramentas a este site. O tempo total de ponta a ponta do push à produção: 3 minutos e 40 segundos em média. O passo mais lento é o build Docker multi-plataforma em ~90 segundos. Todo o resto está em cache, quase instantâneo.

Lições de Dois Anos de Iteração#

Vou encerrar com os erros que cometi para que você não precise.

Fixe as versões das suas actions. uses: actions/checkout@v4 é ok, mas para produção, considere uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (o SHA completo). Uma action comprometida poderia exfiltrar seus secrets. O incidente do tj-actions/changed-files em 2025 provou que isso não é teórico.

Não faça cache de tudo. Uma vez fiz cache de node_modules diretamente (não apenas do store do pnpm) e passei duas horas debugando uma falha fantasma de build causada por bindings nativos desatualizados. Faça cache do store do gerenciador de pacotes, não dos módulos instalados.

Defina timeouts. Todo job deveria ter timeout-minutes. O padrão é 360 minutos (6 horas). Se seu deploy trava porque a conexão SSH caiu, você não quer descobrir seis horas depois quando consumiu todos seus minutos mensais.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Use concurrency com sabedoria. Para PRs, cancel-in-progress: true está sempre certo — ninguém se importa com o resultado de CI de um commit que já foi sobrescrito por force-push. Para deploys em produção, defina como false. Você não quer que um commit rápido cancele um deploy que está no meio do rollout.

Teste seu arquivo de workflow. Use act (https://github.com/nektos/act) para rodar workflows localmente. Não vai pegar tudo (secrets não estão disponíveis, e o ambiente do runner difere), mas pega erros de sintaxe YAML e bugs lógicos óbvios antes de fazer push.

Monitore seus custos de CI. Minutos do GitHub Actions são gratuitos para repos públicos e baratos para privados, mas acumulam. Builds Docker multi-plataforma são 2x os minutos (um por plataforma). Estratégias de teste com matrix multiplicam seu runtime. Fique de olho na página de cobrança.

O melhor pipeline CI/CD é aquele em que você confia. Confiança vem de confiabilidade, observabilidade e melhoria incremental. Comece com um pipeline simples de lint-test-build. Adicione Docker quando precisar de reprodutibilidade. Adicione deploy SSH quando precisar de automação. Adicione notificações quando precisar de confiança. Não construa o pipeline completo no primeiro dia — você vai errar as abstrações.

Posts Relacionados