DevOps / CI/CD 9 min lectura

Despliegue continuo con GitHub Actions y Docker Compose en VPS: guía completa con secrets, healthcheck y rollback

Configura un pipeline CI/CD real que haga deploy automático en tu VPS cada vez que haces push a main. Con SSH seguro, manejo de secretos, variables de entorno, healthcheck de servicios, notificaciones en Discord y rollback manual. Todo con Docker Compose y GitHub Actions, listo para producción.

Por Equipo Starbyte

Despliegue continuo con GitHub Actions y Docker Compose en VPS: guía completa con secrets, healthcheck y rollback

Despliegue continuo con GitHub Actions y Docker Compose en VPS: guía completa con secrets, healthcheck y rollback

Problema real: Desarrollas una aplicación con varios servicios (backend, frontend, base de datos) definidos en Docker Compose. Cada vez que fusionas cambios en la rama main, debes conectarte por SSH al VPS, hacer git pull, reconstruir imágenes, lanzar los contenedores y verificar que todo funcione. Es repetitivo, propenso a errores humanos y te roba tiempo valioso. Peor aún, si algo falla en producción, no tienes un mecanismo rápido de rollback.

Este post te guía para montar un pipeline de despliegue continuo (CD) usando GitHub Actions para automatizar el deploy en tu VPS cada push a main, con gestión segura de secretos, verificación de salud de los servicios y una estrategia simple de vuelta atrás.


Requisitos previos

  • Un VPS con Linux (Ubuntu 22.04/24.04) y Docker + Docker Compose v2 instalados.
  • Tu código fuente en un repositorio privado (o público si no hay secretos sensibles en el código, aunque lo habitual es privado) en GitHub.
  • La aplicación definida en un archivo docker-compose.yml (o varios) en la raíz del repo.
  • Clave SSH pública/privada para acceder al VPS sin contraseña.
  • Un webhook o bot de Discord (opcional) para notificaciones de deploy.

1. Preparar el VPS: usuario de despliegue dedicado

Por seguridad, crea un usuario específico para los deploys, sin privilegios totales pero con acceso a Docker.

sudo adduser deploy --disabled-password
sudo usermod -aG docker deploy

Verifica que puede ejecutar Docker sin sudo:

sudo -u deploy docker ps

Crea el directorio donde residirá la aplicación:

sudo mkdir -p /opt/app
sudo chown deploy:deploy /opt/app

2. Configurar acceso SSH sin contraseña desde GitHub Actions

Genera un par de claves SSH (en tu máquina local o en el VPS, mejor en entorno aislado):

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key

Esto crea deploy_key (privada) y deploy_key.pub (pública).

En el VPS, añade la pública al authorized_keys del usuario deploy:

sudo -u deploy mkdir -p /home/deploy/.ssh
echo "contenido-de-deploy_key.pub" | sudo tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

Prueba la conexión desde tu máquina local (con la clave privada):

ssh -i deploy_key deploy@IP_DEL_VPS

3. Configurar secretos en GitHub

En tu repositorio, ve a Settings > Secrets and variables > Actions y añade estos secretos:

  • VPS_HOST: la IP o dominio del VPS.
  • VPS_USER: deploy
  • VPS_SSH_KEY: el contenido completo del archivo deploy_key (incluyendo cabecera y pie).
  • REGISTRY_USER y REGISTRY_PASSWORD (opcional, si usas un registro privado como Docker Hub o GHCR).
  • DISCORD_WEBHOOK_URL (opcional).

Ejemplo de cómo pegar la clave:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

4. Workflow de GitHub Actions: .github/workflows/deploy.yml

Crea el archivo en tu rama principal con este contenido:

name: Deploy to VPS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout código
        uses: actions/checkout@v4

      - name: Configurar SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.VPS_SSH_KEY }}

      - name: Desplegar en VPS
        env:
          VPS_USER: ${{ secrets.VPS_USER }}
          VPS_HOST: ${{ secrets.VPS_HOST }}
        run: |
          ssh -o StrictHostKeyChecking=accept-new $VPS_USER@$VPS_HOST << 'EOF'
            set -e
            cd /opt/app
            git pull origin main
            docker compose pull
            docker compose up -d --remove-orphans
            docker compose ps
          EOF

      - name: Healthcheck básico
        env:
          VPS_USER: ${{ secrets.VPS_USER }}
          VPS_HOST: ${{ secrets.VPS_HOST }}
        run: |
          ssh $VPS_USER@$VPS_HOST "docker compose -f /opt/app/docker-compose.yml ps -q | xargs docker inspect -f '{{.Name}} {{.State.Health.Status}}'"

      - name: Notificar en Discord (éxito)
        if: success()
        uses: sarisia/actions-status-discord@v1
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
          title: "✅ Deploy exitoso en ${{ github.repository }}"
          description: "Commit ${{ github.sha }} desplegado en VPS."
          color: 0x00ff00

      - name: Notificar en Discord (fallo)
        if: failure()
        uses: sarisia/actions-status-discord@v1
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
          title: "❌ Deploy FALLIDO en ${{ github.repository }}"
          description: "Revisa los logs del workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
          color: 0xff0000

Explicación de partes clave:

  • webfactory/ssh-agent carga la clave privada en el agente SSH del runner, así el comando ssh no necesita -i.
  • El heredoc (<< 'EOF') ejecuta comandos en el VPS de forma segura.
  • docker compose pull actualiza las imágenes (si usas una etiqueta latest o una fija, pero mejor etiqueta versionada; ver buenas prácticas).
  • --remove-orphans limpia contenedores de servicios que ya no estén en el compose.
  • El healthcheck básico asume que tus servicios tienen healthcheck configurado en el compose; si no, puedes hacer curl a un endpoint de estado.

5. Configurar healthchecks en Docker Compose (ejemplo)

En tu docker-compose.yml, añade para un servicio web:

services:
  backend:
    image: mi-backend:latest
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Así, el paso de healthcheck del workflow leerá el estado real.


6. Rollback rápido en caso de emergencia

El pipeline no sobrescribe el directorio git ni las imágenes antiguas, solo actualiza. Para volver atrás manualmente:

ssh deploy@VPS "cd /opt/app && git log --oneline -5"

Elige el commit anterior bueno y ejecuta:

ssh deploy@VPS "cd /opt/app && git checkout <commit-hash> && docker compose up -d"

Si la imagen de Docker es la que falla y no el código, puedes volver a una versión anterior de la imagen (si seguiste la práctica de etiquetar con versión explícita). Cambia la etiqueta en docker-compose.yml o ejecuta:

docker compose up -d backend=mi-backend:v1.2.3

O simplemente edita el compose y vuelve a hacer up.


7. Errores frecuentes y soluciones

Error Causa Solución
Permission denied (publickey) Clave SSH mal copiada, formato incorrecto, o no añadida a authorized_keys en el usuario correcto Revisa que el secreto VPS_SSH_KEY tenga los saltos de línea exactos; en VPS asegura permisos 600 y propiedad correcta de .ssh.
docker: Got permission denied al ejecutar docker ps Usuario deploy no está en grupo docker o no ha hecho logout/login Ejecuta newgrp docker o reinicia sesión; también puede requerir reinicio del servicio docker.
docker compose no encontrado en VPS Docker Compose v1 en lugar de v2, o no instalado como plugin Instala Docker Compose v2 o usa docker-compose (con guión). Ajusta el comando en el workflow.
git pull falla por conflictos locales Cambios manuales en VPS (logs, configuraciones) que interfieren con el pull Nunca modifiques archivos rastreados en /opt/app. Si es necesario, usa .gitignore o volúmenes externos.
Healthcheck muestra "starting" o "unhealthy" tras deploy El contenedor necesita más tiempo para iniciar Aumenta start_period o interval en el healthcheck.
El webhook de Discord no recibe notificaciones Token del webhook inválido o falta la acción en el workflow Verifica que el secreto DISCORD_WEBHOOK_URL esté completo y que la acción sarisia/actions-status-discord esté correctamente escrita.

8. Casos prácticos de uso

8.1 Aplicación multi-repo

Puedes tener un repositorio solo de infraestructura (compose, configuraciones) y que el pipeline se dispare allí, trayendo imágenes de varios registros. El paso git pull solo actualiza ese repo, y docker compose pull descarga las nuevas imágenes.

8.2 Deploy solo si cambia una carpeta específica

Si usas monorepo, añade filtros:

on:
  push:
    branches: [main]
    paths:
      - 'backend/**'

8.3 Variables de entorno secretas

Para pasar secretos que no deben estar en el repo (como claves de API), crea un archivo .env en el VPS previamente y no lo versiones. En el workflow, tras git pull, copia ese archivo si es necesario o bien incluye un step que escriba las variables desde secretos de GitHub (menos seguro porque quedan en logs si no se oculta).

Mejor práctica: archivo .env manejado directamente en el servidor y respaldado aparte.


9. Buenas prácticas

  • Usa siempre etiquetas de imagen explícitas (con versión semántica) en lugar de latest en producción. Así sabes exactamente qué versión está corriendo y puedes hacer rollback más seguro.
  • Nunca almacenes secretos en el repositorio (ni en variables de entorno no ignoradas). Usa GitHub Secrets para la configuración del pipeline y .env local en el VPS con permisos 600.
  • Haz que el pipeline sea idempotente (docker compose up -d lo es). Evita lógica que dependa de estado previo.
  • Configura healthchecks reales en tus servicios, no solo curl a una ruta; que verifiquen conexiones a base de datos, caché, etc.
  • Protege el acceso SSH: además de clave sin passphrase, restringe el origen en el firewall (solo IPs de GitHub Actions runners, o usa actions/checkout con ssh sin exponer puerto 22 globalmente). Los runners tienen IPs dinámicas, así que una opción es un túnel inverso o Cloudflare Tunnel, pero escapa del alcance. Al menos, usa StrictHostKeyChecking=accept-new solo la primera vez.
  • Mantén los logs limpios: en el step Desplegar en VPS, no imprimas variables sensibles (evita set -x si incluye secretos). Usa set -e para detener ante errores, pero asegura que los comandos críticos no fallen en silencio.

10. Cierre con idea clave

Dejar de hacer deploys manuales no es un lujo, es una necesidad para escalar tu productividad y reducir riesgos. Con GitHub Actions y Docker Compose en un VPS, puedes tener un flujo de entrega continua sólido, seguro y verificable en pocos pasos. El beneficio inmediato: cada nuevo merge a main se convierte en una actualización en producción sin fricción y con la garantía de que los servicios están sanos.

Etiquetas: #github-actions #docker-compose #vps #despliegue-continuo #cicd #automatizacion