Express.js es el framework web más usado de Node.js para construir APIs y backends. Node ejecuta el código JavaScript en un solo hilo, así que en producción necesitas un gestor de procesos que lo arranque con el sistema, lo reinicie si cae y reparta la carga entre los núcleos. En este tutorial desplegarás una aplicación Express en Ubuntu 24.04 con Node.js 24 LTS, la ejecutarás con PM2 en modo cluster bajo un usuario dedicado y la publicarás detrás de Nginx con HTTPS, con recargas sin cortes en cada actualización.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 1 GB de RAM y 2 vCPU para aprovechar el modo cluster.
  • Un usuario no root con privilegios sudo.
  • Un dominio con un registro A apuntando a la IP del servidor. En esta guía se usa your_domain.
  • Los puertos 80 y 443 accesibles desde Internet.

Paso 1: Instalar Node.js 24 LTS

Ubuntu 24.04 incluye Node.js 18, que ya no tiene soporte. Instala la rama LTS actual desde el repositorio de NodeSource. Primero añade su clave de firma:

sudo apt update
sudo apt install ca-certificates curl gnupg
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg

Añade el repositorio de Node.js 24 e instala el paquete, que incluye npm:

echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install nodejs

Comprueba la versión instalada:

node --version
v24.21.0

Instala PM2 de forma global:

sudo npm install -g pm2
pm2 --version

Paso 2: Crear el usuario de la aplicación

La aplicación y PM2 se ejecutarán con un usuario propio, sin privilegios de administrador. Su directorio personal, /srv/expressapp, contendrá el código y la carpeta .pm2 con el estado y los logs de PM2:

sudo adduser --system --group --home /srv/expressapp --shell /bin/bash expressapp

A partir de aquí, los comandos marcados como "como expressapp" se ejecutan en una shell de ese usuario, que abres con:

sudo -iu expressapp

Sal de ella con exit cuando el paso vuelva a usar sudo.

Paso 3: Preparar la aplicación Express

Como expressapp, crea el proyecto e instala Express 5 y Helmet, que añade cabeceras de seguridad HTTP:

mkdir ~/app && cd ~/app
npm init -y
npm pkg set type=commonjs
npm install express helmet

Si ya tienes tu aplicación en un repositorio, clónala en ~/app y ejecuta npm ci --omit=dev en su lugar; el resto de la guía es igual. Para seguir con el ejemplo, crea app.js:

nano ~/app/app.js
const express = require('express');
const helmet = require('helmet');

// Carga .env del directorio de trabajo si existe
try {
  process.loadEnvFile();
} catch (err) {
  if (err.code !== 'ENOENT') throw err;
}

const app = express();
app.set('trust proxy', 'loopback');
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '1mb' }));

app.get('/health', (req, res) => {
  res.json({ status: 'ok', pid: process.pid, uptime: Math.round(process.uptime()) });
});

app.get('/', (req, res) => {
  res.json({ message: 'Hola desde Express', ip: req.ip });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Error interno del servidor' });
});

const port = Number(process.env.PORT) || 3000;
const host = process.env.HOST || '127.0.0.1';

const server = app.listen(port, host, (err) => {
  if (err) throw err;
  console.log(`Express escuchando en ${host}:${port} (PID ${process.pid})`);
  // Avisa a PM2 de que la instancia está lista (wait_ready)
  if (process.send) process.send('ready');
});

function shutdown(signal) {
  console.log(`${signal} recibido, cerrando servidor`);
  server.close(() => process.exit(0));
  // Cierra también las conexiones keep-alive inactivas
  server.closeIdleConnections();
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

Hay tres detalles pensados para producción:

  • trust proxy con loopback hace que req.ip sea la IP real del cliente que envía Nginx en X-Forwarded-For, y no 127.0.0.1.
  • process.send('ready') le indica a PM2 que la instancia ya acepta conexiones. Durante una recarga, PM2 no detiene la instancia antigua hasta recibir esa señal de la nueva.
  • PM2 detiene los procesos con SIGINT. El manejador deja de aceptar conexiones y sale cuando terminan las peticiones en curso.

Guarda la configuración y los secretos en un archivo .env que solo pueda leer el usuario de la aplicación. process.loadEnvFile() lo carga al arrancar:

nano ~/app/.env
PORT=3000
HOST=127.0.0.1
# DATABASE_URL=postgres://app:[email protected]:5432/app
chmod 600 ~/app/.env

Prueba la aplicación antes de meterla en PM2:

node app.js
Express escuchando en 127.0.0.1:3000 (PID 5120)

Detenla con Ctrl+C.

Paso 4: Ejecutar la aplicación con PM2 en modo cluster

El archivo de ecosistema describe cómo arrancar la aplicación y se versiona junto al código. Como expressapp, créalo:

nano ~/app/ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'expressapp',
      script: 'app.js',
      cwd: '/srv/expressapp/app',
      exec_mode: 'cluster',
      instances: 'max',
      env_production: {
        NODE_ENV: 'production',
      },
      max_memory_restart: '512M',
      wait_ready: true,
      listen_timeout: 10000,
      kill_timeout: 10000,
      time: true,
    },
  ],
};
  • exec_mode: 'cluster' con instances: 'max' arranca un proceso por núcleo y todos comparten el puerto 3000. Usa un número fijo (por ejemplo 2) si el servidor también ejecuta una base de datos.
  • wait_ready y listen_timeout hacen que PM2 espere la señal ready hasta 10 segundos.
  • kill_timeout da 10 segundos a cada proceso para cerrar antes de forzarlo.
  • max_memory_restart reinicia una instancia que supere 512 MB, una red de seguridad frente a fugas de memoria.
  • time añade la fecha a cada línea de log.

Arranca la aplicación con el entorno de producción:

cd ~/app
pm2 start ecosystem.config.js --env production
pm2 list
id  name        mode     pid   uptime  ↺  status  mem
0   expressapp  cluster  5301  2s      0  online  58.1mb
1   expressapp  cluster  5308  2s      0  online  57.9mb

Llama varias veces a /health y verás que responden procesos distintos:

curl -s http://127.0.0.1:3000/health; echo
curl -s http://127.0.0.1:3000/health; echo
{"status":"ok","pid":5301,"uptime":8}
{"status":"ok","pid":5308,"uptime":8}

Por defecto PM2 escribe los logs en ~/.pm2/logs sin límite de tamaño. Instala el módulo de rotación y guarda la lista de procesos para que PM2 la restaure al arrancar:

pm2 install pm2-logrotate
pm2 save
exit

Paso 5: Arrancar PM2 con el sistema

PM2 genera una unidad systemd que lo arranca con el usuario indicado y restaura los procesos guardados con pm2 save. Con tu usuario sudo, ejecuta:

sudo pm2 startup systemd -u expressapp --hp /srv/expressapp

El comando crea y activa el servicio pm2-expressapp. Compruébalo:

systemctl status pm2-expressapp --no-pager
● pm2-expressapp.service - PM2 process manager
     Loaded: loaded (/etc/systemd/system/pm2-expressapp.service; enabled; preset: enabled)
     Active: active (running) since ...

Para confirmar que todo vuelve tras un reinicio, ejecuta sudo reboot y, al volver a entrar, sudo -iu expressapp pm2 list.

Paso 6: Configurar Nginx como proxy inverso

Instala Nginx y permite el tráfico web en UFW:

sudo apt install nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Crea el sitio:

sudo nano /etc/nginx/sites-available/expressapp
upstream expressapp {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    client_max_body_size 10m;

    location / {
        proxy_pass http://expressapp;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }

    location = /health {
        proxy_pass http://expressapp;
        access_log off;
    }
}

Si tu aplicación usa WebSockets (Socket.IO, por ejemplo), añade en location / las cabeceras Upgrade $http_upgrade y Connection "upgrade" en lugar de Connection "".

Activa el sitio, desactiva el de por defecto y recarga Nginx:

sudo ln -s /etc/nginx/sites-available/expressapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Desde tu equipo, comprueba que la IP que ve Express es la tuya y no la de Nginx:

curl -s http://your_domain/
{"message":"Hola desde Express","ip":"203.0.113.25"}

Paso 7: Activar HTTPS

Instala Certbot y obtén un certificado de Let's Encrypt. Certbot modifica el sitio para escuchar en 443 y redirigir HTTP a HTTPS:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain --redirect
sudo certbot renew --dry-run

Comprueba el resultado:

curl -sI https://your_domain/health
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json; charset=utf-8
Strict-Transport-Security: max-age=31536000; includeSubDomains
...

La cabecera Strict-Transport-Security y el resto de cabeceras de seguridad las añade Helmet.

Paso 8: Desplegar nuevas versiones sin cortes

pm2 reload reinicia las instancias del cluster de una en una: arranca la nueva, espera su señal ready y solo entonces cierra la antigua, así que siempre hay procesos atendiendo peticiones. Un despliegue típico desde Git, como expressapp, es:

cd ~/app
git pull
npm ci --omit=dev
pm2 reload ecosystem.config.js --env production

Pasar el archivo de ecosistema a pm2 reload aplica también los cambios que hayas hecho en él. Verifica que los PID han cambiado y que la aplicación responde:

pm2 list
curl -s http://127.0.0.1:3000/health

Si la nueva versión falla, vuelve al commit anterior y recarga. Anota antes el hash con git log --oneline -n 2:

git checkout previous_commit_hash
npm ci --omit=dev
pm2 reload ecosystem.config.js --env production

Cuando hayas corregido el fallo, vuelve a la rama con git checkout main antes del siguiente git pull.

Usa pm2 restart solo cuando quieras parar todas las instancias a la vez, por ejemplo tras actualizar Node.js. Después de una actualización de PM2, ejecuta pm2 update para que el demonio en memoria use la nueva versión.

Solución de problemas

pm2 list no muestra procesos tras un reinicio del servidor. Olvidaste pm2 save después de arrancar la aplicación, o ejecutaste pm2 startup para otro usuario. Revisa systemctl status pm2-expressapp y vuelve a hacer pm2 save como expressapp.

Las instancias se reinician en bucle (columna ↺ creciendo). La aplicación falla al arrancar. Mira el error con pm2 logs expressapp --lines 50. Si ves listen_timeout agotado, la aplicación no está llamando a process.send('ready').

Nginx devuelve 502 Bad Gateway. La aplicación no escucha en 127.0.0.1:3000. Comprueba con sudo ss -ltnp 'sport = :3000' y revisa /var/log/nginx/error.log.

req.ip devuelve 127.0.0.1. Falta app.set('trust proxy', 'loopback') o la cabecera X-Forwarded-For en el bloque de Nginx.

EACCES al instalar paquetes. Estás ejecutando npm install con tu usuario en un directorio de expressapp, o con sudo. Instala siempre las dependencias como expressapp para que los archivos tengan el propietario correcto.

Conclusión

Tu aplicación Express corre ahora con Node.js 24 LTS bajo un usuario sin privilegios, repartida en todos los núcleos por PM2, arranca con el sistema y se publica con HTTPS a través de Nginx. Las actualizaciones se hacen con pm2 reload sin perder peticiones. Como siguientes pasos, puedes añadir límites de peticiones en Nginx con limit_req, conectar la aplicación a una base de datos PostgreSQL o automatizar el despliegue desde tu CI con los comandos del paso 8.