← Volver al blog
·8 min de lectura

Configurar CI/CD con GitHub Actions para deploy automatico en VPS

DevOpsGitHub Actions

Por que CI/CD en un VPS propio

Las plataformas PaaS como Vercel, Netlify o Railway simplifican el despliegue: conectas tu repo, configuras un par de variables y listo. Pero hay escenarios donde un VPS sigue siendo la mejor opcion:

  • Control total: Decides la version exacta de Node.js, la configuracion de Nginx, los limites de memoria, las reglas de firewall. No dependes de las decisiones de producto de un tercero.
  • Costo predecible: Un VPS de 5-10 USD/mes puede alojar multiples aplicaciones. En una PaaS, cada proyecto adicional incrementa el costo.
  • Multiples servicios: Si tu stack incluye una API, una SPA, una landing y quizas una base de datos, todo puede convivir en el mismo servidor con configuraciones de Nginx independientes.
  • Sin vendor lock-in: Tu pipeline es un archivo YAML estandar. Si manana migras de GitHub a GitLab, la logica se adapta con cambios minimos.

El precio de esta flexibilidad es que debes construir tu propio pipeline de despliegue. GitHub Actions lo hace sorprendentemente accesible.

Preparar el VPS

Antes de configurar el pipeline, el servidor necesita estar listo para recibir despliegues automatizados.

Crear un usuario dedicado para deploys

Evita usar root para despliegues automatizados. Crea un usuario con permisos limitados:

# En el VPS
sudo adduser deploy --disabled-password
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

Generar un par de llaves SSH

Genera una llave SSH sin passphrase (GitHub Actions no puede ingresar passphrases interactivamente):

# En tu maquina local
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

Esto genera dos archivos: deploy_key (privada) y deploy_key.pub (publica).

Copia la llave publica al VPS:

# Agregar la publica al servidor
cat ~/.ssh/deploy_key.pub | ssh root@tu-servidor \
  "cat >> /home/deploy/.ssh/authorized_keys"

# Ajustar permisos
ssh root@tu-servidor "chmod 600 /home/deploy/.ssh/authorized_keys && \
  chown -R deploy:deploy /home/deploy/.ssh"

Permisos sobre el directorio de la aplicacion

# En el VPS
sudo mkdir -p /var/www/mi-app
sudo chown -R deploy:deploy /var/www/mi-app

Si necesitas que el usuario deploy pueda reiniciar servicios con PM2 o systemd, configura permisos especificos via sudoers:

# Permitir reiniciar solo un servicio especifico sin password
echo "deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mi-app" | \
  sudo tee /etc/sudoers.d/deploy-mi-app

Configurar secretos en GitHub

Los valores sensibles se almacenan como encrypted secrets en el repositorio. Ve a Settings > Secrets and variables > Actions y crea:

| Secret | Valor | |---|---| | VPS_HOST | IP o dominio del servidor (ej: 64.176.15.110) | | VPS_USER | Usuario para SSH (ej: deploy) | | VPS_SSH_KEY | Contenido completo de deploy_key (la llave privada) | | VPS_PORT | Puerto SSH si no es 22 (opcional) |

Para copiar la llave privada correctamente:

# Copia TODO el contenido, incluyendo las lineas BEGIN y END
cat ~/.ssh/deploy_key | pbcopy  # macOS
cat ~/.ssh/deploy_key | xclip   # Linux

El workflow completo

Crea el archivo .github/workflows/deploy.yml en tu repositorio:

name: Deploy to VPS

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  NODE_VERSION: '20'
  APP_DIR: /var/www/mi-app

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build
        env:
          NEXT_PUBLIC_SITE_URL: https://mi-dominio.com

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.VPS_PORT || 22 }} \
            ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy with rsync
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='node_modules' \
            --exclude='.env' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.VPS_PORT || 22 }}" \
            ./out/ \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ env.APP_DIR }}/

      - name: Post-deploy commands
        run: |
          ssh -i ~/.ssh/deploy_key \
            -p ${{ secrets.VPS_PORT || 22 }} \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
            cd /var/www/mi-app
            echo "Deploy completed at $(date)"
          ENDSSH

      - name: Cleanup SSH key
        if: always()
        run: rm -f ~/.ssh/deploy_key

Desglosemos las decisiones importantes.

Por que npm ci en vez de npm install

npm ci es la version determinista de install: borra node_modules, lee exclusivamente package-lock.json y falla si hay discrepancias con package.json. En un pipeline CI/CD quieres builds reproducibles, no resoluciones de dependencias sorpresivas.

rsync vs scp

rsync es superior a scp para despliegues porque:

  • Transferencia incremental: Solo copia archivos que cambiaron. Si tu build genera 200 archivos y solo 10 cambiaron, rsync transfiere solo esos 10.
  • Flag --delete: Elimina archivos en el destino que ya no existen en el origen. Esto evita que queden artefactos de builds anteriores.
  • Compresion en transito (-z): Reduce el ancho de banda necesario.

ssh-keyscan para known_hosts

La primera vez que te conectas a un servidor via SSH, el cliente pide confirmacion del fingerprint. En un pipeline automatizado no hay humano que confirme, asi que ssh-keyscan agrega el fingerprint al archivo known_hosts de forma automatica.

Limpieza de la llave SSH

El paso Cleanup SSH key usa if: always() para ejecutarse siempre, incluso si pasos anteriores fallan. Esto evita que la llave privada quede en el filesystem del runner.

Deploy condicional por rama

En proyectos con multiples ambientes, puedes dirigir cada rama a un servidor o directorio diferente:

on:
  push:
    branches: [main, develop]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Set environment
        id: env
        run: |
          if [[ "${{ github.ref_name }}" == "main" ]]; then
            echo "app_dir=/var/www/mi-app" >> $GITHUB_OUTPUT
            echo "site_url=https://mi-dominio.com" >> $GITHUB_OUTPUT
          else
            echo "app_dir=/var/www/mi-app-staging" >> $GITHUB_OUTPUT
            echo "site_url=https://staging.mi-dominio.com" >> $GITHUB_OUTPUT
          fi

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_SITE_URL: ${{ steps.env.outputs.site_url }}

      - name: Deploy
        run: |
          rsync -avz --delete \
            -e "ssh -i ~/.ssh/deploy_key" \
            ./out/ \
            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ steps.env.outputs.app_dir }}/

Cache de dependencias

El action setup-node con cache: 'npm' almacena node_modules entre ejecuciones. Pero si quieres mas control, puedes cachear explicitamente:

- name: Cache node_modules
  uses: actions/cache@v4
  id: cache-deps
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: npm ci

La clave del cache es el hash de package-lock.json. Si el lockfile no cambio, las dependencias se restauran del cache en segundos en lugar de ejecutar npm ci (que puede tomar 30-60 segundos dependiendo del proyecto).

Manejo de variables de entorno

Nunca incluyas archivos .env en el repositorio. Para variables que tu aplicacion necesita en build time:

- name: Build
  run: npm run build
  env:
    NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
    NEXT_PUBLIC_GA_ID: ${{ vars.GA_TRACKING_ID }}

Nota la distincion: secrets.* para valores sensibles (llaves API, tokens), vars.* para valores de configuracion no sensibles (IDs de tracking, URLs publicas). Las vars se configuran en el mismo panel de Settings pero son visibles en los logs.

Para variables que la aplicacion necesita en runtime (un backend Node.js, por ejemplo), crealas directamente en el servidor:

- name: Create .env on server
  run: |
    ssh -i ~/.ssh/deploy_key \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} << ENDSSH
      cat > /var/www/mi-app/.env << 'ENV'
      DATABASE_URL=${{ secrets.DATABASE_URL }}
      JWT_SECRET=${{ secrets.JWT_SECRET }}
      NODE_ENV=production
      ENV
    ENDSSH

Deploy de aplicaciones Node.js con PM2

Si tu aplicacion no es estatica sino un servidor Node.js (Express, Fastify, Next.js en modo server), necesitas reiniciar el proceso despues del despliegue:

- name: Deploy and restart
  run: |
    rsync -avz --delete \
      --exclude='node_modules' \
      --exclude='.env' \
      -e "ssh -i ~/.ssh/deploy_key" \
      ./ \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/mi-api/

    ssh -i ~/.ssh/deploy_key \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
      cd /var/www/mi-api
      npm ci --production
      pm2 restart mi-api --update-env || pm2 start npm \
        --name "mi-api" -- start
    ENDSSH

El patron pm2 restart || pm2 start cubre ambos casos: si el proceso ya existe, lo reinicia; si es la primera vez, lo crea.

Estrategia de rollback

Un rollback automatizado requiere mantener versiones anteriores. Una estrategia simple pero efectiva:

- name: Deploy with versioning
  run: |
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)
    RELEASE_DIR="/var/www/mi-app/releases/$TIMESTAMP"

    ssh -i ~/.ssh/deploy_key \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
      "mkdir -p $RELEASE_DIR"

    rsync -avz --delete \
      -e "ssh -i ~/.ssh/deploy_key" \
      ./out/ \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:$RELEASE_DIR/

    ssh -i ~/.ssh/deploy_key \
      ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} << ENDSSH
      # Actualizar symlink al nuevo release
      ln -sfn $RELEASE_DIR /var/www/mi-app/current

      # Mantener solo los ultimos 5 releases
      cd /var/www/mi-app/releases
      ls -1dt */ | tail -n +6 | xargs rm -rf
    ENDSSH

Nginx apunta a /var/www/mi-app/current. Para hacer rollback, solo cambias el symlink al release anterior:

# Rollback manual en el VPS
cd /var/www/mi-app/releases
ln -sfn $(ls -1dt */ | sed -n '2p') /var/www/mi-app/current

Notificaciones de deploy

Agrega un paso final para recibir notificacion cuando el deploy termine o falle:

- name: Notify success
  if: success()
  run: |
    curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
      -H "Content-Type: application/json" \
      -d "{
        \"content\": \"Deploy exitoso en ${{ github.ref_name }} - commit ${{ github.sha }}\"
      }"

- name: Notify failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
      -H "Content-Type: application/json" \
      -d "{
        \"content\": \"FALLO el deploy en ${{ github.ref_name }} - ver ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"
      }"

Conclusion

Un pipeline CI/CD para VPS no necesita ser complejo. Con GitHub Actions, SSH y rsync tienes los bloques fundamentales para automatizar despliegues de forma confiable. La inversion inicial de configurar secretos, permisos y el workflow se paga rapido: cada git push a main dispara un despliegue consistente, sin pasos manuales que puedan fallar o saltarse.

Empieza con un workflow basico que haga build y rsync, y ve agregando capas (cache, rollback, notificaciones, deploys condicionales) a medida que tu proyecto lo necesite.