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.
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#
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
- productionQuatro triggers, cada um servindo a um propósito diferente:
pushparamainé o trigger de deploy em produção. Código mergeado? Manda.pull_requestroda 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_dispatchdá 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#
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 lintJobs 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#
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.
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: 1Por 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:
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 testfail-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#
- 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:
- 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.
- 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:
- Match exato: mesmas dependências E mesmos arquivos fonte. Cache hit completo, build quase instantâneo.
- Match parcial (dependências): dependências iguais mas código mudou. Build recompila apenas arquivos alterados.
- 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.
- 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=maxtype=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.
- 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:
# 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#
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=maxVou 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:
- Autenticação é gratuita.
GITHUB_TOKENestá automaticamente disponível em todo workflow — não precisa guardar credenciais do Docker Hub. - Proximidade. Imagens são puxadas da mesma infraestrutura onde seu CI roda. Pulls durante o CI são rápidos.
- Visibilidade. Imagens são vinculadas ao seu repositório na interface do GitHub. Você as vê na aba Packages.
Builds Multi-Plataforma#
platforms: linux/amd64,linux/arm64Essa 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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shIsso é 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.
# ecosystem.config.js já tem:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netMesmo 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:
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.comSem 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:
{
"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:
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#
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:
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 linttypecheck— Sem erros de tipotest— Todos os testes passandobuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueAcompanhando o Fluxo#
Quando eu faço push para main:
- Lint, Type Check e Test iniciam simultaneamente. Três runners, três jobs paralelos. Se qualquer um falha, o pipeline para.
- Build roda apenas se os três passarem. Valida que a aplicação compila e produz output funcional.
- Docker constrói a imagem de produção e faz push para ghcr.io. Multi-plataforma, com cache de layers.
- Deploy faz SSH no VPS, puxa a nova imagem, inicia um novo container, faz health-check, troca o Nginx e faz limpeza.
- 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:
- Lint, Type Check e Test rodam. Mesmas barreiras de qualidade.
- Build roda para verificar que o projeto compila.
- Docker e Deploy são pulados (as condições
iflimitam ao branchmainapenas).
Quando preciso de um deploy emergencial (pular testes):
- Clico em "Run workflow" na aba Actions.
- Seleciono
skip_tests: true. - Lint e typecheck ainda rodam (não posso pular esses — não confio em mim mesmo tanto assim).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestUse 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.