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:
El precio de esta flexibilidad es que debes construir tu propio pipeline de despliegue. GitHub Actions lo hace sorprendentemente accesible.
Antes de configurar el pipeline, el servidor necesita estar listo para recibir despliegues automatizados.
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
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"
# 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
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
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.
npm ci en vez de npm installnpm 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 es superior a scp para despliegues porque:
--delete: Elimina archivos en el destino que ya no existen en el origen. Esto evita que queden artefactos de builds anteriores.-z): Reduce el ancho de banda necesario.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.
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.
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 }}/
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).
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
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.
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
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 }}\"
}"
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.