GitOps na VPS: deploy automático ao push

GitOps é a prática de usar o Git como fonte única de verdade para o estado da infraestrutura, qualquer mudança na aplicação é feita via commit, e a infraestrutura se sincroniza automaticamente. Na VPS, a implementação mais simples usa Watchtower (atualização automática de imagens Docker) ou um webhook que dispara o deploy no servidor sem que o CI precise de acesso SSH.

Watchtower: atualização automática de containers

Watchtower monitora o registry e atualiza containers automaticamente quando uma nova imagem é publicada:

yaml
# docker-compose.yml — adicionar Watchtower
services:
  app:
    image: ghcr.io/usuario/minha-api:main
    restart: always
    labels:
      - "com.centurylinklabs.watchtower.enable=true"   # monitorar este container

  watchtower:
    image: containrrr/watchtower
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ~/.docker/config.json:/config.json:ro    # credenciais do registry
    environment:
      WATCHTOWER_POLL_INTERVAL: 60          # verificar a cada 60 segundos
      WATCHTOWER_CLEANUP: "true"            # remover imagens antigas
      WATCHTOWER_LABEL_ENABLE: "true"       # monitorar apenas containers com label
      WATCHTOWER_NOTIFICATIONS: "slack"
      WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: ${SLACK_WEBHOOK}
      WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER: "watchtower-vps-prod"

Webhook de deploy: CI notifica a VPS

Alternativa ao Watchtower: a VPS expõe um endpoint que o GitHub Actions chama após publicar a imagem:

python
# Na VPS — servidor de webhook simples com Python
# /opt/webhook/webhook.py

from http.server import HTTPServer, BaseHTTPRequestHandler
import subprocess
import hmac
import hashlib
import os

SECRET = os.environ["WEBHOOK_SECRET"].encode()

class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/deploy":
            self.send_response(404)
            self.end_headers()
            return

        # Verificar assinatura HMAC
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        signature = self.headers.get("X-Hub-Signature-256", "")
        expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()

        if not hmac.compare_digest(signature, expected):
            self.send_response(403)
            self.end_headers()
            return

        # Executar deploy
        subprocess.Popen(["/opt/scripts/deploy.sh"])
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"Deploy iniciado")

    def log_message(self, *args):
        pass  # silenciar logs padrão

if __name__ == "__main__":
    server = HTTPServer(("127.0.0.1", 9000), WebhookHandler)
    server.serve_forever()

Chamar webhook a partir do GitHub Actions

Após o build e push, o workflow notifica a VPS para puxar a nova imagem:

yaml
# .github/workflows/deploy.yml
      - name: Notificar VPS para deploy
        run: |
          PAYLOAD='{"ref":"main","sha":"${{ github.sha }}"}'
          SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.WEBHOOK_SECRET }}" | awk '{print "sha256="$2}')

          curl -f -X POST https://seudominio.com.br/webhook/deploy \
            -H "Content-Type: application/json" \
            -H "X-Hub-Signature-256: $SIGNATURE" \
            -d "$PAYLOAD"

# O Nginx roteia /webhook/ para localhost:9000 (webhook server)
# nginx.conf:
# location /webhook/ {
#     allow all;
#     proxy_pass http://127.0.0.1:9000;
# }

deploy.sh chamado pelo webhook

Script de deploy na VPS que puxa a nova imagem e reinicia o container:

bash
#!/bin/bash
# /opt/scripts/deploy.sh
set -euo pipefail
LOG="/var/log/deploy.log"
exec >> "$LOG" 2>&1

echo "=== Deploy: $(date) ==="

# Autenticar no registry (se imagem privada)
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin 2>/dev/null

cd /opt/minha-api

# Puxar nova imagem
docker compose pull app

# Reiniciar apenas o container app
docker compose up -d --no-deps app

# Health check
sleep 5
if curl -sf http://localhost:3000/health > /dev/null; then
    echo "Deploy bem-sucedido"
else
    echo "Health check falhou — rollback"
    docker compose up -d --no-deps --scale app=0 app
    exit 1
fi

Comparação: Push vs Pull GitOps

Duas abordagens de GitOps com tradeoffs distintos:

bash
# Push GitOps (mais comum em VPS):
# CI/CD faz SSH na VPS e executa o deploy diretamente
# + Simples de implementar
# + Feedback imediato no pipeline
# - CI precisa de credenciais SSH da VPS
# - Gargalo de segurança se CI for comprometido

# Pull GitOps (padrão em Kubernetes com ArgoCD/Flux):
# A VPS monitora o repositório/registry e atualiza sozinha
# + Sem credenciais de deploy no CI
# + VPS nunca expõe acesso de entrada para deploy
# - Lag de alguns segundos/minutos até sincronizar
# - Mais complexo de debugar

# Para VPS simples: Push GitOps (GitHub Actions via SSH)
# Para múltiplas VPS ou containers: Pull GitOps (Watchtower)
# Para Kubernetes: ArgoCD ou Flux (Pull GitOps nativo)

Não quer configurar manualmente?

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

Perguntas frequentes

Com as devidas precauções: sim. Use WATCHTOWER_LABEL_ENABLE=true para só atualizar containers explicitamente marcados. Evite atualizar banco de dados e serviços de infraestrutura automaticamente. Configure notificações para saber quando uma atualização ocorreu. Para produção crítica: prefira deploy explícito via CI/CD com aprovação manual.

Volumes Docker são persistidos independente do lifecycle do container, um docker compose up -d --no-deps app nunca apaga os volumes de banco de dados. O risco de perda de dados vem de migrações de banco mal aplicadas, não do restart do container. Sempre aplique migrações antes de subir o novo código.

Sim, é o método mais simples: um webhook ou cron na VPS executa git pull + npm run build + pm2 reload. Não é "GitOps real" (o estado não é 100% declarativo), mas funciona bem para projetos simples. Desvantagem: requer Node.js/Python instalado no host e o build acontece na VPS (lento, consome recursos de produção).

Push GitOps: git revert do commit problemático + push → dispara novo deploy com a versão anterior. Ou: docker compose up -d --no-deps app com a imagem da versão anterior (docker compose exec app docker images). Pull GitOps com Watchtower: tag específica de versão estável no docker-compose.yml em vez de :main.

Sim. Use path filters no CI para dispara builds apenas quando os diretórios relevantes mudam: on.push.paths: ["apps/api/**"]. No Watchtower: cada serviço com imagem independente se atualiza separadamente. Para monorepos complexos: Nx ou Turborepo têm suporte nativo a builds afetados.