Webhook de deploy: atualizar a VPS via GitHub webhook

Para quem quer deploy automático sem configurar GitHub Actions, um servidor de webhook simples na própria VPS resolve: o GitHub envia uma requisição POST a cada push, o servidor valida a assinatura e executa o script de deploy. Simples, sem dependência de runners externos.

Servidor de webhook em Node.js

API Express que recebe webhooks do GitHub e executa o deploy:

typescript
// webhook-server.ts — servidor dedicado para deploy
import express from 'express'
import crypto from 'crypto'
import { exec } from 'child_process'
import { promisify } from 'util'

const execAsync = promisify(exec)
const app = express()

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!
const DEPLOY_SCRIPT = process.env.DEPLOY_SCRIPT ?? '/opt/scripts/deploy.sh'
const DEPLOY_BRANCH = process.env.DEPLOY_BRANCH ?? 'refs/heads/main'

// Verificar assinatura HMAC do GitHub:
function verificarAssinatura(payload: Buffer, signature: string): boolean {
  const esperado = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(signature))
}

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-hub-signature-256'] as string

  if (!signature || !verificarAssinatura(req.body, signature)) {
    return res.status(401).json({ error: 'Assinatura inválida' })
  }

  const payload = JSON.parse(req.body.toString())

  // Só executar para push na branch correta:
  if (payload.ref !== DEPLOY_BRANCH) {
    return res.json({ message: 'Branch ignorada' })
  }

  res.json({ message: 'Deploy iniciado', commit: payload.after })

  // Executar deploy assincronamente (não bloquear o webhook):
  try {
    const { stdout, stderr } = await execAsync(`bash ${DEPLOY_SCRIPT}`, {
      timeout: 300_000,  // 5 minutos de timeout
      env: { ...process.env, GIT_COMMIT: payload.after },
    })
    console.log('Deploy concluído:', stdout)
  } catch (err) {
    console.error('Deploy falhou:', err)
  }
})

app.listen(9000, '127.0.0.1', () => console.log('Webhook server rodando na porta 9000'))

Script de deploy chamado pelo webhook

Script shell que executa o deploy e envia notificação:

bash
#!/bin/bash
# /opt/scripts/deploy.sh

set -e
APP_DIR="/opt/minha-api"
LOG_FILE="/var/log/deploy.log"

log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"; }
alerta() {
    curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
      -d "chat_id=${TELEGRAM_CHAT_ID}" --data-urlencode "text=$1" > /dev/null
}

log "=== Iniciando deploy ==="
log "Commit: ${GIT_COMMIT:-desconhecido}"

cd "$APP_DIR"

# Salvar versão atual para rollback:
PREVIOUS=$(git rev-parse --short HEAD)
log "Versão anterior: $PREVIOUS"

# Baixar código mais recente:
git fetch origin main
git reset --hard origin/main

# Instalar dependências:
npm ci --only=production

# Build (se aplicável):
if [ -f tsconfig.json ]; then
    npm run build
fi

# Migrations:
if npm run --if-present db:migrate; then
    log "Migrations executadas"
fi

# Reiniciar aplicação:
pm2 reload minha-api --update-env

# Verificar que o serviço está respondendo:
sleep 5
if curl -sf http://localhost:3000/health > /dev/null; then
    CURRENT=$(git rev-parse --short HEAD)
    log "Deploy concluído: $PREVIOUS → $CURRENT"
    alerta "✅ Deploy concluído: $PREVIOUS → $CURRENT"
else
    log "ERRO: healthcheck falhou, revertendo para $PREVIOUS"
    alerta "🔴 Deploy falhou! Revertendo para $PREVIOUS"
    git reset --hard "$PREVIOUS"
    npm ci --only=production
    pm2 reload minha-api --update-env
    exit 1
fi

Configurar webhook no GitHub

Registrar o endpoint no repositório do GitHub:

bash
# 1. Expor o webhook server via Nginx:
# /etc/nginx/conf.d/webhook.conf:
server {
    listen 443 ssl http2;
    server_name deploy.seudominio.com.br;

    ssl_certificate /etc/letsencrypt/live/deploy.seudominio.com.br/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/deploy.seudominio.com.br/privkey.pem;

    location /webhook {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

# 2. Configurar no GitHub:
# Repo → Settings → Webhooks → Add webhook
# Payload URL: https://deploy.seudominio.com.br/webhook
# Content type: application/json
# Secret: mesmo valor do WEBHOOK_SECRET no .env
# Events: ✓ Just the push event

# 3. Rodar o webhook server com PM2:
pm2 start dist/webhook-server.js --name webhook-deploy
pm2 save

# 4. Testar manualmente:
curl -X POST https://deploy.seudominio.com.br/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=ASSINATURA" \
  -d '{"ref":"refs/heads/main","after":"abc123"}'

Evitar deploys concorrentes

Usar lock file para não rodar dois deploys ao mesmo tempo:

typescript
// webhook-server.ts — adicionar controle de concorrência:
let deploying = false

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  // ... verificar assinatura ...

  if (deploying) {
    return res.status(429).json({ message: 'Deploy já em andamento, aguardando...' })
  }

  const payload = JSON.parse(req.body.toString())
  if (payload.ref !== DEPLOY_BRANCH) {
    return res.json({ message: 'Branch ignorada' })
  }

  res.json({ message: 'Deploy enfileirado' })

  deploying = true
  try {
    await execAsync(`bash ${DEPLOY_SCRIPT}`, {
      timeout: 300_000,
      env: { ...process.env, GIT_COMMIT: payload.after },
    })
  } finally {
    deploying = false
  }
})

# Alternativa via lock file no script shell:
LOCK_FILE="/tmp/deploy.lock"

if [ -f "$LOCK_FILE" ]; then
    # Verificar se o processo que criou o lock ainda existe:
    LOCK_PID=$(cat "$LOCK_FILE")
    if kill -0 "$LOCK_PID" 2>/dev/null; then
        echo "Deploy já em andamento (PID $LOCK_PID)"
        exit 0
    fi
fi

echo $ > "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT

Webhook vs GitHub Actions: quando usar cada um

Comparativo para decidir a melhor abordagem de CI/CD:

bash
# GitHub Actions — use quando precisar de:
# ✓ Build no ambiente CI (Node.js, Python, Go)
# ✓ Testes automatizados com serviços (PostgreSQL, Redis)
# ✓ Push para registry Docker (GHCR, Docker Hub)
# ✓ Deploy em múltiplos ambientes (staging, prod)
# ✓ Aprovação manual antes do deploy
# ✓ Matrix de testes (múltiplas versões/OS)
# ✓ Cache de dependências gerenciado

# Webhook self-hosted — use quando:
# ✓ A VPS já tem o Node.js e todas as dependências
# ✓ O deploy é simples: git pull + pm2 reload
# ✓ Você quer evitar os 2.000 min/mês do GitHub gratuito
# ✓ Quer menor latência (sem cold start do runner)
# ✓ VPS sem acesso à internet (intranet)
# ✗ Evite para builds pesados (TypeScript, Docker)
# ✗ Evite para múltiplos ambientes com aprovação

# Híbrido (melhor dos dois):
# GitHub Actions: testes + build + push Docker
# Webhook ou SSH Action: apenas o deploy final na VPS
# → CI no GitHub, deploy via SSH/webhook

Não quer configurar manualmente?

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

Perguntas frequentes

Sem validação: qualquer pessoa que descubra a URL pode disparar um deploy no seu servidor enviando um POST com JSON falso. A assinatura HMAC-SHA256 garante que a requisição veio realmente do GitHub, eles assinam o payload com o secret que você configurou. Sempre valide a assinatura antes de executar qualquer código. Use crypto.timingSafeEqual para evitar timing attacks na comparação.

Não, use porta diferente (ex: 9000) ou path diferente (/webhook) e coloque ambos atrás do Nginx. Separar em processos diferentes é mais seguro: se o webhook server cair, a API continua funcionando. Dê ao webhook server apenas as permissões necessárias (execução do script de deploy, não acesso ao banco de dados da aplicação).

Grave cada deploy em arquivo de log com timestamp, commit, resultado e duração. Exemplo: echo "$(date),$(git rev-parse HEAD),success,45s" >> /var/log/deploys.csv. Ou crie uma tabela simples no banco com INSERT INTO deploys (commit_sha, deployed_at, status, duration_s). O Grafana pode visualizar o histórico com a query diretamente dessa tabela.

Sim, o GitHub envia o webhook independente da visibilidade do repositório. A única diferença: o payload do webhook não inclui o conteúdo dos arquivos, apenas metadados (SHA do commit, autor, branch). O script de deploy ainda precisa de credenciais (chave SSH ou token) para executar git pull do repositório privado no servidor.

O servidor que recebe o webhook pode fazer SSH nos outros servidores e executar o deploy remotamente (orquestrador). Ou: configure o webhook no GitHub para múltiplos endpoints (um por servidor). Ou: use um message queue, o webhook publica a mensagem de deploy e cada servidor consome e executa de forma independente. Para mais de 3 servidores: considere mudar para GitHub Actions com uma matrix de servidores.