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.
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#
adduser deploy
usermod -aG sudo deployConfigura la Autenticación por Clave SSH#
En tu maquina local:
ssh-copy-id deploy@your-server-ipLuego deshabilita la autenticación por contrasena completamente:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdSi 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#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableEso es todo. Cuatro reglas. Solo tráfico SSH y web pasan.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localEdita /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banTres 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#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesTu 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:
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:
node -v # v22.x.x o lo que sea la LTS actual
npm -vEl 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:
nvm alias default 22Esto 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#
npm install -g pm2El 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.
// 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:
pm2 startup systemd
# Copia y ejecuta el comando que muestra
pm2 saveEsto 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:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true50MB 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#
sudo apt install nginx -yLa Configuración#
# /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:
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 nginxSiempre 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.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot 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:
sudo systemctl list-timers | grep certbotPrueba que la renovacion funciona:
sudo certbot renew --dry-runSi 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.
#!/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 1Hazlo ejecutable:
chmod +x deploy.shDespliega desde tu maquina local:
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:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logNueve 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"#
pm2 monitEsto 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:
pm2 describe akousaEsto 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"#
sudo certbot certificatesLista todos los certificados con sus fechas de expiracion. Si la renovacion automática fallo:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"El disco esta lleno"#
df -h
du -sh /var/log/*
pm2 flushpm2 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#
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.
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:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519Ahora 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.