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

    $ runstack deploy --plan starter

    Não quer configurar manualmente?

    Não quer configurar manualmente? Implante o VPS para APIs em menos de 3 minutos com a Runstack. Infraestrutura da OPEN DATACENTER, com servidores no Brasil.

    Perguntas frequentes