Saltar al contenido
·14 min de lectura

La Configuración de VPS Que Realmente Funciona: Node.js, PM2, Nginx y Deploys Sin Tiempo de Inactividad

La configuración exacta de despliegue en VPS que uso en producción — hardening de Ubuntu, PM2 en modo cluster, Nginx como reverse proxy, SSL y un script de deploy que no me ha fallado. Sin teoría, solo lo que funciona.

Compartir:X / TwitterLinkedIn

Este blog corre en un VPS de $10/mes. No Vercel, no AWS, no un cluster de Kubernetes gestionado por un equipo de seis personas. Una sola maquina Ubuntu con Nginx, PM2 y un script de bash que despliega en menos de 30 segundos.

He probado los otros caminos. He usado Vercel (genial hasta que necesitas cron jobs, WebSockets persistentes o simplemente control). He usado AWS (genial si disfrutas pasar la mitad del dia en politicas IAM). Siempre termino volviendo a un VPS.

Pero aquí esta el problema: cada tutorial de "desplegar en VPS" en internet se detiene en el camino feliz. Te muestran como instalar Node.js y ejecutar node server.js y lo llaman producción. Luego tu servidor recibe ataques de fuerza bruta por SSH, tu proceso muere a las 3 AM porque nadie configuro un gestor de procesos, y tu certificado SSL expiro hace tres meses.

Esta es la guia que me hubiera gustado tener. Todo aquí esta probado en batalla — esta configuración exacta sirve la página que estas leyendo ahora mismo.

Empieza Con Seguridad, No Con Código#

Antes de siquiera pensar en Node.js, asegura la maquina. Las instancias de VPS nuevas son objetivos. Los bots automatizados empiezan a golpear tu puerto SSH a los minutos de aprovisionarla.

Crea un Usuario No-Root#

bash
adduser deploy
usermod -aG sudo deploy

Configura la Autenticación por Clave SSH#

En tu maquina local:

bash
ssh-copy-id deploy@your-server-ip

Luego deshabilita la autenticación por contrasena completamente:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Si te saltas esto, veras miles de intentos fallidos de login en tus logs de autenticación en cuestion de dias. Eso no es paranoia — es un martes cualquiera en la internet pública.

Firewall Con UFW#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Eso es todo. Cuatro reglas. Solo tráfico SSH y web pasan.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Edita /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Tres intentos fallidos de SSH y estas baneado por una hora. He visto a Fail2Ban bloquear cientos de IPs en un solo dia. Funciona.

Actualizaciones de Seguridad Automaticas#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Tu servidor ahora auto-instalara parches de seguridad. Una cosa menos que olvidar.

Node.js: Usa NVM, No apt#

Veo esto en cada tutorial: sudo apt install nodejs. No lo hagas.

Los repositorios de paquetes de Ubuntu traen versiones antiguas de Node.js. Incluso el PPA de NodeSource va por detrás. Y cuando necesitas alternar entre Node 20 y Node 22 para diferentes proyectos, estas atascado.

NVM resuelve esto:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*

Ahora verifica:

bash
node -v  # v22.x.x o lo que sea la LTS actual
npm -v

El consejo no obvio: cuando instalas paquetes globales con NVM (como PM2), estan ligados a esa versión de Node. Si cambias de versión con nvm use, tus globales desaparecen. Establece tu default y mantente en el servidor:

bash
nvm alias default 22

Esto me mordio exactamente una vez. Una vez fue suficiente.

PM2: El Gestor de Procesos Qué Se Gana Su Lugar#

PM2 es la diferencia entre "desplegado" y "listo para producción". Maneja gestión de procesos, clustering, rotacion de logs, auto-reinicio ante crashes y scripts de inicio. Gratis.

Instala y Configura#

bash
npm install -g pm2

El Archivo de Configuración Ecosystem#

No inicies aplicaciones con flags de CLI. Usa un archivo ecosystem.config.js. Esta versionado, es reproducible y auto-documentado.

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "akousa",
      script: "node_modules/.bin/next",
      args: "start -p 3002",
      cwd: "/var/www/akousa.net",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3002,
      },
      // Apagado elegante
      kill_timeout: 5000,
      listen_timeout: 10000,
      wait_ready: false,
      // Logging
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      error_file: "/var/log/pm2/akousa-error.log",
      out_file: "/var/log/pm2/akousa-out.log",
      merge_logs: true,
      // Auto-reinicio ante fallos
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      // No observar en produccion
      watch: false,
    },
  ],
};

Dejame explicar las decisiones que importan:

instances: 2 en lugar de "max": En un VPS pequeño con 1-2 nucleos, "max" suena inteligente pero generara procesos que compiten por recursos durante los builds. Dos instancias te da reinicios sin tiempo de inactividad mientras dejas margen. En una maquina de 4+ nucleos, claro, usa "max".

exec_mode: "cluster": Esto es lo que habilita los reinicios sin tiempo de inactividad. Sin modo cluster, pm2 reload es solo un restart elegante. Con modo cluster, PM2 reinicia instancias una a la vez — tu aplicación nunca se cae completamente.

max_memory_restart: "500M": Tu aplicación Next.js tiene un memory leak? PM2 la reiniciara antes de que haga OOM-kill a tu servidor. Esto me ha salvado de alertas a las 2 AM mas de una vez.

kill_timeout: 5000: Le da a tu aplicación 5 segundos para terminar las solicitudes en curso antes de que PM2 la mate por la fuerza. El valor por defecto (1600ms) es demasiado agresivo para aplicaciones con conexiones a base de datos.

watch: false: He visto gente dejar watch: true en producción. PM2 entonces reinicia la aplicación cada vez que un archivo de log cambia. Tu aplicación entra en un ciclo de reinicios. No lo hagas.

Script de Inicio#

Haz que PM2 sobreviva a los reinicios:

bash
pm2 startup systemd
# Copia y ejecuta el comando que muestra
pm2 save

Esto genera un servicio systemd. Después de un reinicio del servidor, tu aplicación vuelve automáticamente. Pruebalo — reinicia tu servidor y verifica. No asumas.

Rotacion de Logs#

Los logs eventualmente se comeran tu disco. Instala el modulo de rotacion:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

50MB maximo por archivo, conservar 7 archivos rotados, comprimir los antiguos. Sin esto, he visto /var/log llenar un disco de 25GB en tres semanas en una aplicación con tráfico moderado.

Nginx: El Reverse Proxy Que Hace Más De Lo Que Crees#

"¿Por qué no simplemente exponer Node.js directamente en el puerto 80?"

Porque Nginx maneja cosas en las que Node.js no debería gastar ciclos: terminacion SSL, servir archivos estaticos, compresion gzip, buffering de solicitudes, limites de conexión y manejo elegante de clientes lentos. Esta escrito en C y construido especificamente para esto.

Instala#

bash
sudo apt install nginx -y

La Configuración#

nginx
# /etc/nginx/sites-available/akousa.net
 
upstream node_app {
    server 127.0.0.1:3002;
    keepalive 64;
}
 
server {
    listen 80;
    listen [::]:80;
    server_name akousa.net www.akousa.net;
 
    # Redirigir todo HTTP a HTTPS
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name akousa.net www.akousa.net;
 
    # SSL (gestionado por Certbot — estas lineas se agregan automaticamente)
    ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # Headers de seguridad
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Compresion gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        application/wasm;
 
    # Configuracion del proxy
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
 
        # Headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # Soporte WebSocket (por si alguna vez lo necesitas)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # Timeouts — generosos pero no infinitos
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
 
        # Buffering — deja que Nginx maneje los clientes lentos
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
 
    # Assets estaticos de Next.js — deja que Nginx los sirva directamente
    location /_next/static/ {
        alias /var/www/akousa.net/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }
 
    # Archivos estaticos publicos
    location /static/ {
        alias /var/www/akousa.net/public/static/;
        expires 30d;
        access_log off;
    }
 
    # Bloquear acceso a archivos punto
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Habilitalo:

bash
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Siempre ejecuta nginx -t antes de recargar. Una vez empuje una configuración rota y tire el sitio porque me salte la verificación de sintaxis. Los cinco caracteres nginx -t me habrian ahorrado treinta minutos de depuración en panico.

Cosas que la mayoria de los tutoriales omiten en esta configuración:

Bloque upstream con keepalive 64: Nginx reutiliza conexiones hacia tu backend Node.js en lugar de abrir una nueva conexión TCP por cada solicitud. Esto importa bajo carga.

proxy_buffering on: Nginx lee la respuesta completa de Node.js en memoria, luego la envia al cliente a la velocidad que el cliente pueda manejar. Sin esto, un cliente lento en una conexión 3G ocupa tu worker de Node.js.

Servir _next/static/ directamente: Estos son assets hasheados e inmutables. Deja que Nginx los sirva desde disco con un header de cache de 365 dias. Tus procesos Node.js no deberian estar perdiendo tiempo en esto.

SSL en Cinco Minutos#

Let's Encrypt resolvio SSL. Si todavia estas pagando por certificados en 2026, deja de hacerlo.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot te pedira tu email, aceptar los ToS y automáticamente modificara tu configuración de Nginx para incluir las directivas SSL. Eso es todo.

Verifica la Renovacion Automática#

Certbot instala un timer de systemd que verifica dos veces al dia y renueva certificados dentro de los 30 dias antes de la expiracion:

bash
sudo systemctl list-timers | grep certbot

Prueba que la renovacion funciona:

bash
sudo certbot renew --dry-run

Si el dry run pasa, nunca mas pensaras en SSL. Si falla, generalmente es porque el puerto 80 esta bloqueado (revisa tus reglas de UFW) o Nginx no esta corriendo.

Algo que me atrapo: si configuraste Nginx antes de ejecutar Certbot, asegurate de que tu bloque server este escuchando en el puerto 80 sin la redireccion HTTPS primero. Certbot necesita alcanzar el puerto 80 para el desafio HTTP-01. Después de que Certbot se ejecute exitosamente, entonces agrega la redireccion.

El Script de Deploy#

Este es el script que se ejecuta cada vez que empujo a producción. Sin plataforma de CI/CD, sin GitHub Actions. Solo SSH y bash.

bash
#!/bin/bash
# deploy.sh — despliegue con casi-cero tiempo de inactividad
 
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
 
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
 
log "=== Deploy iniciado ==="
 
cd "$APP_DIR"
 
# Obtener el codigo mas reciente
log "Obteniendo ultimos cambios..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# Instalar dependencias
log "Instalando dependencias..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# Compilar
log "Compilando aplicacion..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "ERROR: La compilacion fallo. Abortando deploy."
    exit 1
fi
 
# Recargar PM2 (sin tiempo de inactividad en modo cluster)
log "Recargando PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# Health check con reintentos
log "Ejecutando health check..."
for i in $(seq 1 $MAX_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
    if [ "$HTTP_CODE" = "200" ]; then
        log "Health check exitoso (HTTP $HTTP_CODE)"
        log "=== Deploy completado exitosamente ==="
        exit 0
    fi
    log "Intento de health check $i/$MAX_RETRIES (HTTP $HTTP_CODE). Reintentando en ${RETRY_INTERVAL}s..."
    sleep $RETRY_INTERVAL
done
 
log "ERROR: Health check fallo despues de $MAX_RETRIES intentos"
log "Revirtiendo al estado anterior de PM2..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1

Hazlo ejecutable:

bash
chmod +x deploy.sh

Despliega desde tu maquina local:

bash
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"

Decisiones clave en este script:

set -euo pipefail: El script se detiene inmediatamente ante cualquier error. Sin esto, un npm install fallido continua silenciosamente al paso de compilacion, y obtienes un error criptico que desperdicia 20 minutos para depurar.

rm -rf .next antes de compilar: Next.js tiene un cache de compilacion que ocasionalmente produce output obsoleto. Me paso una vez — una página mostraba contenido viejo a pesar de que el código fuente estaba actualizado. Eliminar el directorio de compilacion agrega quiza 15 segundos al build pero garantiza output fresco.

pm2 reload en lugar de pm2 restart: Esta es la parte de cero tiempo de inactividad. En modo cluster, reload realiza un reinicio progresivo — levanta nuevas instancias con el código actualizado, espera a que esten listas, luego apaga elegantemente las antiguas. En ningun momento hay cero instancias corriendo.

Health check con reintentos: Next.js tarda unos segundos en calentarse después del reinicio. El script espera hasta 30 segundos (10 reintentos x 3 segundos), verificando si la aplicación responde con HTTP 200. Si no lo hace, algo esta mal y necesitas saberlo inmediatamente — no enterarte por un usuario.

Rollback ante fallo: Si el health check falla después de todos los reintentos, el script reinicia PM2 (que carga el último estado guardado). No es un rollback perfecto, pero es mejor que dejar el servidor en un estado roto.

Cuando Las Cosas Se Rompen a las 2 AM#

Esto es lo que realmente he depurado en esta configuración exacta:

"El sitio esta caido"#

Primeros comandos a ejecutar:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

Nueve de cada diez veces, pm2 logs te dice inmediatamente que paso. Una variable de entorno faltante, una conexión a base de datos fallida o una promesa rechazada no manejada.

"La memoria sigue creciendo"#

bash
pm2 monit

Esto te da un dashboard en vivo de CPU y memoria por proceso. Si la memoria sube constantemente sin nivelarse, tienes un leak. La configuración max_memory_restart en tu ecosystem config es tu red de seguridad — PM2 reiniciara el proceso antes de que tumbe el servidor.

Para investigacion mas profunda:

bash
pm2 describe akousa

Esto muestra uptime, conteo de reinicios y snapshots de memoria. Si ves 47 reinicios en las ultimas 24 horas, esa es tu pista.

"El certificado SSL expiro"#

bash
sudo certbot certificates

Lista todos los certificados con sus fechas de expiracion. Si la renovacion automática fallo:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"El disco esta lleno"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 flush limpia todos los archivos de log de PM2 inmediatamente. Si no configuraste la rotacion de logs (te lo dije), aquí es donde sientes el dolor.

El Comando Qué Ejecuto Cada Mañana#

bash
ssh deploy@akousa.net "pm2 status && df -h / && uptime"

Tres cosas en una línea: estan mis procesos corriendo, esta bien mi disco, esta el servidor sobrecargado. Toma dos segundos. Detecta problemas antes que los usuarios.

Lo Qué La Mayoria de las Guias No Te Dicen#

Tu paso de compilacion es tu mayor vulnerabilidad. En un VPS de 1GB de RAM, npm run build para una aplicación Next.js puede consumir 800MB+ de memoria. Si PM2 esta corriendo tu aplicación en dos instancias durante el build, haras OOM. Soluciones: usa un archivo swap (al menos 2GB), o detiene la aplicación durante los builds y acepta unos segundos de inactividad. Yo uso swap.

bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

--legacy-peer-deps en tu comando de instalación es un mal olor de código, no una solución. Lo uso porque algunos paquetes en mi arbol de dependencias no han actualizado sus rangos de peer dependencies. Cada pocos meses intento quitarlo. Algun dia funcionara. Hasta entonces, lanzo.

Prueba tu script de deploy desde cero. Clona tu repositorio en un servidor nuevo y ejecuta cada paso manualmente. La cantidad de problemas "funciona en mi maquina" que se esconden en scripts de deploy es vergonzosa. Encontre tres problemas en el mio cuando hice esto — paquetes globales faltantes, permisos de archivos incorrectos y una ruta que solo existia por una configuración manual previa.

Pon la IP de tu servidor en tu configuración SSH. Deja de escribir direcciones IP:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Ahora ssh akousa es todo lo que necesitas. Las cosas pequenas se acumulan.

La Lista de Verificación Completa#

Antes de darlo por terminado:

  • Usuario no-root con acceso sudo
  • Solo autenticación por clave SSH, autenticación por contrasena deshabilitada
  • UFW habilitado con solo los puertos necesarios abiertos
  • Fail2Ban protegiendo SSH
  • Actualizaciones de seguridad automaticas habilitadas
  • Node.js instalado via NVM
  • PM2 corriendo tu aplicación en modo cluster
  • Script de inicio de PM2 configurado (sobrevive a reinicios)
  • Rotacion de logs de PM2 instalada
  • Nginx como reverse proxy con headers apropiados
  • SSL via Let's Encrypt con renovacion automática
  • Script de deploy con health checks
  • Archivo swap configurado (para margen en compilacion)
  • Probado: reinicia el servidor y verifica que todo vuelva

Ese último punto es el que la gente se salta. No seas esa persona. Reinicia el servidor, espera 60 segundos y verifica si tu aplicación esta en línea. Si no lo esta, tus scripts de inicio estan mal configurados y te enteraras en el peor momento posible.

Es Esto "Enterprise-Grade"?#

No. Y ese es el punto.

Esta configuración sirve este blog de forma confiable por menos de $10/mes. Se despliega en 30 segundos con un solo comando. Entiendo cada pieza. Cuando algo se rompe, se exactamente donde buscar.

Podría usar Docker? Claro. Podría usar Kubernetes? Tecnicamente. Podría configurar un pipeline completo de CI/CD con ambientes de staging y despliegues canary? Absolutamente.

Pero he aprendido que la mejor infraestructura es la que realmente entiendes, puedes depurar a las 2 AM y no cuesta mas de lo que el proyecto genera. Para un sitio personal, un MVP de SaaS o una startup pequeña — esta es esa configuración.

Lanza primero. Escala cuando lo necesites. Y siempre, siempre, prueba tu script de deploy en un servidor nuevo.