Cambiar tema

Estrategias de despliegue con GitHub Actions: pipeline CD de VPS a plataformas cloud

Easton editorial illustration: developer problem-solving desk

Introducción

A las tres de la madrugada miraba los logs de GitHub Actions mientras las líneas rojas de error seguían subiendo. «Host key verification failed». Otra vez un problema de SSH.

Era el quinto fallo de despliegue seguido. En local todo pasaba, pero al hacer push a GitHub explotaba. En ese momento quise maldecir — y también entendí algo: elegir la estrategia de despliegue es mucho más complejo de lo que pensaba.

Ya sea un VPS que gestionas tú o plataformas como Vercel o Cloudflare Pages, cada opción tiene sus trampas. Si eliges mal, las noches de depuración solo aumentan.

En este artículo repasamos varias estrategias de despliegue con GitHub Actions para ayudarte a encontrar la que encaje con tu proyecto.


Despliegue SSH en VPS: clásico pero fiable

Al principio me resistía mucho al despliegue en VPS. Me parecía demasiado lío: claves SSH, known_hosts, parámetros de rsync…

Después de tropezar varias veces, descubrí que este enfoque «clásico» es en realidad el más predecible.

Configuración de claves SSH: no las codifiques

El error más común es dónde guardar la clave SSH.

Muchos principiantes escriben la clave privada directamente en el workflow. Grave error. GitHub Secrets es el lugar correcto.

Ve a Settings → Secrets → Actions del repositorio y añade SSH_PRIVATE_KEY. Luego úsala en el workflow así:

- name: Setup SSH
  uses: webfactory/[email protected]
  with:
    ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

Esta action arranca ssh-agent y carga tu clave automáticamente. Cómodo.

known_hosts: evita «Host key verification failed»

La primera vez que SSH se conecta a un servidor, pregunta si confías en ese host. Esa interacción no funciona en CI, así que hay que añadir la huella del servidor a known_hosts de antemano.

Dos formas:

Opción 1: añadir con una action

- name: Add server to known hosts
  uses: webfactory/[email protected]
  with:
    ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
    known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}

Puedes obtener el contenido de SSH_KNOWN_HOSTS así:

ssh-keyscan -H your-server.com >> known_hosts.txt
# Copia el contenido del archivo a GitHub Secrets

Opción 2: configuración manual

- name: Add server to known hosts
  run: |
    mkdir -p ~/.ssh
    ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

La opción 1 es más limpia; la 2 sirve para depurar rápido.

¿rsync o scp?

Para transferir archivos en el despliegue uso rsync. Razones simples:

  • Solo transfiere lo que cambió, ahorra tiempo
  • Puede excluir directorios concretos (p. ej. node_modules)
  • Sincronización incremental

Un comando rsync típico:

- name: Deploy to server
  run: |
    rsync -avz --delete \
      --exclude 'node_modules' \
      --exclude '.git' \
      ./dist/ ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}:/var/www/html/

--delete borra en el destino lo que no existe en el origen. Úsalo con cuidado: un error de ruta puede eliminar archivos importantes.

Comandos post-despliegue: reiniciar el servicio

En un sitio estático, subir archivos basta. Si despliegas una app Node.js, hay que reiniciar el servicio.

Yo uso PM2 para gestionar procesos Node. Tras el despliegue:

- name: Restart application
  run: |
    ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} \
      "cd /var/www/app && pm2 restart all"

O de forma más segura, reiniciando una app concreta:

- name: Restart application
  run: |
    ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} \
      "pm2 restart my-app --update-env"

--update-env recarga variables de entorno, útil cuando cambia la configuración.


Despliegue en plataformas cloud: comodidad gestionada

El problema del VPS es que el servidor lo gestionas tú: parches de seguridad, renovación SSL, reglas de firewall… mucho trabajo operativo.

Las plataformas gestionadas simplifican todo: push, build automático, despliegue automático. Tú te centras en el código.

Vercel: primera opción para frontend

Vercel encaja casi a la perfección con proyectos frontend. Next.js, Astro, React — despliegue en un clic, sin configuración.

Pero si necesitas API backend, ojo: las Serverless Functions de Vercel tienen límite de tiempo (10 s en plan gratuito, 60 s en Pro). Si lo superas, timeout.

Para sitios estáticos o APIs simples, Vercel basta. Servicios backend complejos siguen requiriendo infraestructura propia.

Configuración de GitHub Actions para Vercel:

name: Deploy to Vercel

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Vercel CLI
        run: npm i -g vercel@latest

      - name: Pull Vercel Environment Information
        run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

      - name: Build Project Artifacts
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy Project Artifacts to Vercel
        run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

Genera VERCEL_TOKEN en la consola de Vercel y guárdalo en GitHub Secrets.

Cloudflare Pages: cuota gratuita generosa

La cuota gratuita de Cloudflare Pages supera a la de Vercel: ancho de banda ilimitado y 500 builds al mes — de sobra para proyectos personales.

Además, la CDN global de Cloudflare es muy rápida. En mis pruebas, el acceso desde Asia fue más estable que con Vercel.

Configuración de despliegue:

name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: npm run build

      - name: Deploy
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-project
          directory: dist

Otro plus: R2 también tiene buena cuota gratuita. Puedes guardar assets estáticos en R2 y servirlos con la CDN de Pages para mejorar tiempos de carga.

Netlify: veterano estable

Netlify la uso menos que las otras dos, pero es una plataforma consolidada con ecosistema maduro.

Configuración similar:

- name: Deploy to Netlify
  uses: netlify/actions/cli@master
  with:
    args: deploy --prod
  env:
    NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
    NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

Form handling de Netlify es práctico: procesa envíos de formularios automáticamente, ideal para landing pages sencillas.

Limitaciones de las plataformas gestionadas

Las plataformas gestionadas tampoco lo resuelven todo.

Limitaciones habituales:

  1. Entorno de build acotado: límites de memoria y CPU; proyectos grandes pueden fallar al compilar
  2. Poca personalización: ¿Quieres cambiar nginx? Imposible
  3. Dependencia del proveedor: si cambian políticas o cierran, toca migrar
  4. Acceso desde China: algunas plataformas son inestables (Cloudflare ha mejorado esto)

Si necesitas control total, vuelves al VPS.


Estrategia híbrida: flexibilidad y control

Muchos proyectos no son «solo estático» ni «solo backend». Frontend en Next.js, backend con base de datos y tareas cron…

En ese caso, el despliegue híbrido suele ser la mejor opción.

Páginas estáticas gestionadas + API en VPS

Arquitectura típica:

  • Páginas estáticas (HTML/CSS/JS) en Cloudflare Pages o Vercel
  • Servicio API Node.js en tu VPS
  • Base de datos en el VPS (o gestionada con Supabase/PlanetScale)

Ventajas de combinar lo mejor de cada lado:

  • Frontend con CDN y HTTPS automático
  • Backend con control total, sin límites de la plataforma
  • Baja latencia a la base de datos (API y BD en la misma máquina)

Despliegue multi-etapa con GitHub Actions

Un workflow que despliega en dos destinos:

name: Hybrid Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-path: ./dist
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist

  deploy-frontend:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: my-frontend
          directory: dist

  deploy-backend:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
      - name: Deploy API to VPS
        run: |
          rsync -avz --delete \
            --exclude 'node_modules' \
            ./api/ ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}:/var/www/api/
      - name: Restart API service
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} \
            "cd /var/www/api && npm install && pm2 restart api"

Este workflow tiene tres jobs:

  1. build: compila el proyecto y genera archivos estáticos
  2. deploy-frontend: despliega estáticos en Cloudflare Pages
  3. deploy-backend: despliega la API al VPS y reinicia el servicio

needs: build garantiza que los jobs de despliegue corren tras el build. upload-artifact y download-artifact pasan los artefactos entre jobs.

Separación de variables de entorno

Un reto del despliegue híbrido: frontend y backend no comparten las mismas variables.

El frontend necesita la URL de la API; el backend, la contraseña de la base de datos.

Mi enfoque:

# Job frontend
- name: Set frontend env
  run: |
    echo "API_URL=https://api.mydomain.com" >> $GITHUB_ENV

# Job backend
- name: Deploy with env
  run: |
    ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} \
      "cd /var/www/api && pm2 restart api --update-env DATABASE_URL=${{ secrets.DATABASE_URL }}"

Datos sensibles (contraseña de BD, tokens API) siempre en GitHub Secrets. Datos no sensibles (URL de API) pueden ir en el workflow.


Ejemplo de configuración en producción

A continuación, un workflow completo de despliegue en VPS que cubre los puntos anteriores.

Archivo workflow completo

name: Deploy to VPS

on:
  push:
    branches: [main]
  workflow_dispatch:  # Despliegue manual

env:
  NODE_VERSION: '20'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - 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: Run tests
        run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - 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
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: Setup SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add server to known hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy files
        run: |
          rsync -avz --delete \
            --exclude '.htaccess' \
            ./dist/ ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}

      - name: Verify deployment
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} \
            "ls -la ${{ secrets.DEPLOY_PATH }}"

      - name: Send deployment notification
        if: always()
        run: |
          curl -X POST "${{ secrets.NOTIFICATION_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{"text": "Deployment completed: ${GITHUB_SHA}"}'

Secrets a configurar

Nombre del SecretDescripciónCómo obtenerlo
SSH_PRIVATE_KEYContenido de la clave privada SSHGenerar en local; clave pública en el servidor
SERVER_HOSTIP o dominio del servidorDatos de tu VPS
SERVER_USERUsuario SSHSuele ser root o ubuntu
DEPLOY_PATHRuta de destino del desplieguep. ej. /var/www/html
NOTIFICATION_WEBHOOKURL de notificación del despliegueWebhook de Slack/Telegram

Resolución de problemas frecuentes

Cuando falla el despliegue, los logs pueden abrumar — demasiada información.

Mi orden de revisión:

  1. Conexión SSH: pasos «Setup SSH» y «Add server to known hosts»
    • Si falla, revisa formato de clave y contenido de known_hosts
  2. Transferencia rsync: paso «Deploy files»
    • Si falla, revisa que la ruta exista y los permisos sean correctos
  3. Reinicio del servicio: paso «Verify deployment»
    • Si falla, comprueba que haya archivos en la ruta destino

Truco: añade salida de depuración tras el paso que falla.

- name: Debug SSH connection
  if: failure()
  run: |
    echo "SSH config:"
    cat ~/.ssh/config || echo "No config file"
    echo "Known hosts:"
    cat ~/.ssh/known_hosts || echo "No known_hosts file"
    ssh -v ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} echo "Connection test"

ssh -v muestra logs detallados y suele señalar dónde está el problema.


Conclusión

En resumen: no hay una estrategia de despliegue perfecta, solo la que mejor encaja con tu proyecto.

Recomendaciones:

  • Sitio estático puro (blog, documentación): Cloudflare Pages o Vercel, sin complicaciones
  • API simple + frontend: una plataforma gestionada basta; no hace falta VPS
  • Backend complejo + base de datos: VPS o servidor cloud; el control importa
  • Arquitectura híbrida: frontend gestionado + backend en VPS

Sea cual sea tu elección, el patrón en GitHub Actions es similar: build → transferencia → reinicio. Si separas bien esos tres pasos, depurar es más fácil.

Y si falla el despliegue, no entres en pánico. Revisa los logs por tramos: primero SSH, luego la ejecución de comandos. Un paso de depuración extra suele sacar el problema a la luz.

La próxima vez que falle un despliegue a las tres de la madrugada, ojalá encuentres la causa mucho antes.

Configurar despliegue en VPS con GitHub Actions

Configuración completa del flujo de despliegue por SSH a un VPS con GitHub Actions

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Generar par de claves SSH

    Genera en local una clave SSH dedicada al despliegue:

    • ssh-keygen -t ed25519 -C "deploy@github" -f deploy_key
    • Añade la clave pública (deploy_key.pub) al ~/.ssh/authorized_keys del servidor
    • Guarda el contenido de la clave privada (deploy_key) en GitHub Secrets como SSH_PRIVATE_KEY
  2. 2

    Step 2: Configurar GitHub Secrets

    En el repositorio, Settings → Secrets → Actions, añade:

    • SSH_PRIVATE_KEY: contenido completo de la clave privada
    • SERVER_HOST: IP o dominio del servidor
    • SERVER_USER: usuario SSH (p. ej. root o ubuntu)
    • DEPLOY_PATH: ruta de destino del despliegue
  3. 3

    Step 3: Crear el archivo Workflow

    Crea la configuración de despliegue en .github/workflows/deploy.yml:

    • Añade el paso de configuración SSH (webfactory/ssh-agent-action)
    • Configura known_hosts para evitar fallos de host verification
    • Usa rsync para transferir los artefactos de build
    • Ejecuta comandos de reinicio del servicio tras el despliegue
  4. 4

    Step 4: Probar el flujo de despliegue

    Haz push para disparar el despliegue automático, o lánzalo manualmente:

    • Observa los logs de cada paso
    • Si falla SSH, revisa el formato de la clave y known_hosts
    • Si falla rsync, revisa rutas y permisos
    • Añade pasos de depuración para localizar el problema

FAQ

¿Cómo resolver 'Host key verification failed' al desplegar con GitHub Actions?
Ocurre cuando falta la configuración de known_hosts en la primera conexión SSH al servidor. Dos soluciones:

• Opción 1: usa ssh-keyscan para obtener la huella del servidor y guárdala en el Secret SSH_KNOWN_HOSTS
• Opción 2: en el workflow ejecuta ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts

Recomendamos la opción 1: más limpia y segura.
¿Dónde debe ir la clave SSH? ¿Puedo escribirla directamente en el workflow?
Absolutamente no. La clave privada debe estar en GitHub Secrets y el workflow la referencia con `${{ secrets.SSH_PRIVATE_KEY }}`. Codificar la clave privada en el repositorio expone un riesgo grave de seguridad.
¿Vercel, Cloudflare Pages o Netlify: cuál conviene más para un proyecto personal?
Cloudflare Pages tiene la cuota gratuita más generosa (ancho de banda ilimitado, 500 builds/mes) y mejor estabilidad de acceso en Asia. Vercel ofrece la mejor experiencia para Next.js, pero las Serverless Functions del plan gratuito tienen límite de 10 segundos. Netlify tiene un ecosistema maduro y Form handling muy útil.

Para sitios estáticos puros, prioriza Cloudflare Pages.
¿Qué ventajas tiene una arquitectura de despliegue híbrida?
El frontend en una plataforma gestionada aprovecha CDN y HTTPS automático; el backend en VPS te da control total sin límites de la plataforma. Ideal para proyectos con base de datos, tareas programadas u otros servicios backend complejos.

Con un workflow multi-job de GitHub Actions puedes desplegar en ambos destinos a la vez.
El despliegue falla y los logs son demasiado largos: ¿cómo localizar el problema rápido?
Revisa los logs por tramos, en este orden:

1. Problemas de conexión SSH → pasos Setup SSH y known_hosts
2. Problemas de transferencia rsync → existencia de rutas y permisos
3. Problemas de reinicio del servicio → listado de archivos en la ruta destino

Añadir salida de depuración tras el paso fallido (p. ej. ssh -v) suele exponer la causa al instante.
¿Qué riesgos tiene el parámetro --delete de rsync?
--delete elimina en el destino los archivos que no existen en el origen, manteniendo ambos lados sincronizados. Pero si la ruta está mal configurada, puedes borrar archivos que no debías.

En el primer despliegue evita --delete; actívalo solo cuando confirmes que la ruta es correcta. O usa --delete-excluded para borrar solo archivos excluidos.

10 min de lectura · Publicado el: 7 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog