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

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:
- Entorno de build acotado: límites de memoria y CPU; proyectos grandes pueden fallar al compilar
- Poca personalización: ¿Quieres cambiar nginx? Imposible
- Dependencia del proveedor: si cambian políticas o cierran, toca migrar
- 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:
build: compila el proyecto y genera archivos estáticosdeploy-frontend: despliega estáticos en Cloudflare Pagesdeploy-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 Secret | Descripción | Cómo obtenerlo |
|---|---|---|
SSH_PRIVATE_KEY | Contenido de la clave privada SSH | Generar en local; clave pública en el servidor |
SERVER_HOST | IP o dominio del servidor | Datos de tu VPS |
SERVER_USER | Usuario SSH | Suele ser root o ubuntu |
DEPLOY_PATH | Ruta de destino del despliegue | p. ej. /var/www/html |
NOTIFICATION_WEBHOOK | URL de notificación del despliegue | Webhook de Slack/Telegram |
Resolución de problemas frecuentes
Cuando falla el despliegue, los logs pueden abrumar — demasiada información.
Mi orden de revisión:
- Conexión SSH: pasos «Setup SSH» y «Add server to known hosts»
- Si falla, revisa formato de clave y contenido de known_hosts
- Transferencia rsync: paso «Deploy files»
- Si falla, revisa que la ruta exista y los permisos sean correctos
- 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
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
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
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
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?
• 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?
¿Vercel, Cloudflare Pages o Netlify: cuál conviene más para un proyecto personal?
Para sitios estáticos puros, prioriza Cloudflare Pages.
¿Qué ventajas tiene una arquitectura de despliegue híbrida?
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?
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?
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
Guía completa de GitHub Actions
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Matrix de GitHub Actions: guía práctica de pruebas paralelas multiplataforma y multiversión
Guía práctica de Matrix en GitHub Actions: desde la sintaxis básica hasta exclude/include, fail-fast y max-parallel, con 5 plantillas de workflow listas para producción que te permiten generar pruebas paralelas multiplataforma y multiversión con más del 60% menos de código de configuración
Parte 5 de 10
Siguiente
Gestión de GitHub Actions Secrets: de riesgos de filtración a despliegue sin claves con OIDC
Guía de gestión de GitHub Actions Secrets: estrategia de arquitectura en tres capas, 8 reglas de seguridad, despliegue sin claves con OIDC y protección contra ataques a la cadena de suministro. Lecciones del incidente tj-actions, con ejemplos de workflow YAML y buenas prácticas
Parte 7 de 10



Comentarios
Inicia sesión con GitHub para dejar un comentario