Express is the most widely used web framework for Node.js. In production, a process manager keeps the application running, restarts it after crashes and spreads the load across CPU cores, while Nginx terminates TLS in front of it. In this tutorial you will install Node.js LTS on Ubuntu 24.04, run an Express application under PM2 in cluster mode with a dedicated user, configure zero-downtime reloads and publish the application through Nginx with a Let's Encrypt certificate.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
- A non-root user with
sudoprivileges. - A domain name with a DNS
Arecord pointing to your server's public IP. This guide usesapp.your_domainas the placeholder; replace it with your own hostname. - Ports 80 and 443 reachable from the Internet.
Step 1 - Installing Node.js LTS
The nodejs package in the Ubuntu 24.04 archive is Node.js 18, which is no longer supported upstream. Install the current LTS release (Node.js 24) from the NodeSource repository instead. First install the tools needed to add the repository key:
sudo apt update
sudo apt install -y ca-certificates curl gnupg
Download the NodeSource signing key into /etc/apt/keyrings:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
Add the repository for Node.js 24 and install the package:
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 -y nodejs
Verify the versions of Node.js and npm:
node --version
npm --version
v24.9.0
11.6.0
Now install PM2 globally. It is installed in /usr/lib/node_modules with its command in /usr/bin/pm2, so every user on the server can run it:
sudo npm install -g pm2
pm2 --version
Step 2 - Creating an application user
PM2 runs as the user who starts it and keeps its state in that user's ~/.pm2 directory. Create a dedicated user called nodeapp so the application does not run as root or as your own account:
sudo useradd --create-home --shell /bin/bash nodeapp
The following steps run commands as nodeapp. Open a login shell as that user:
sudo -iu nodeapp
Your prompt changes to nodeapp@... and your current directory is /home/nodeapp.
Step 3 - Creating the Express application
If you already have an Express project, clone it into /home/nodeapp/myapp and install its production dependencies with npm ci --omit=dev. To follow along with an example instead, create a new project:
mkdir ~/myapp && cd ~/myapp
npm init -y
npm install express
Create the entry point:
nano server.js
const express = require('express');
const app = express();
const PORT = Number(process.env.PORT) || 3000;
// Trust the X-Forwarded-* headers set by Nginx on the same host
app.set('trust proxy', 'loopback');
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Hello from Express', pid: process.pid });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
const server = app.listen(PORT, '127.0.0.1', () => {
console.log(`Worker ${process.pid} listening on 127.0.0.1:${PORT}`);
// Tell PM2 this worker is ready to receive traffic
if (process.send) process.send('ready');
});
// PM2 sends SIGINT on stop and reload: finish open requests, then exit
process.on('SIGINT', () => {
console.log(`Worker ${process.pid} shutting down`);
server.close(() => process.exit(0));
});
Three details matter for production:
- The server listens on
127.0.0.1only, so it is reachable through Nginx but not directly from the Internet. process.send('ready')tells PM2 when the worker can accept requests, which PM2 uses for zero-downtime reloads in Step 5.- The
SIGINThandler lets in-flight requests complete before the worker exits.
Test the application:
node server.js
Worker 5231 listening on 127.0.0.1:3000
Open a second SSH session and send a request:
curl http://127.0.0.1:3000/health
{"status":"ok"}
Go back to the first session and stop the application with CTRL+C.
Step 4 - Running the application with PM2 in cluster mode
A PM2 ecosystem file describes how to run the application, so the configuration is versioned and reproducible. Still as nodeapp, create it in the project directory:
nano ~/myapp/ecosystem.config.js
module.exports = {
apps: [
{
name: 'myapp',
script: './server.js',
cwd: '/home/nodeapp/myapp',
// One worker per CPU core, sharing port 3000
instances: 'max',
exec_mode: 'cluster',
// Wait for process.send('ready') before routing traffic to a worker
wait_ready: true,
listen_timeout: 10000,
// Time allowed for a worker to finish open requests after SIGINT
kill_timeout: 10000,
// Restart a worker whose memory grows past this limit
max_memory_restart: '300M',
env_production: {
NODE_ENV: 'production',
PORT: 3000,
DATABASE_URL: 'postgres://myapp:your_strong_password@localhost/myapp',
},
},
],
};
Replace the example DATABASE_URL with your own values. Because this file can contain secrets, make it readable only by nodeapp and keep it out of your Git repository:
chmod 600 ~/myapp/ecosystem.config.js
Start the application with the production environment:
cd ~/myapp
pm2 start ecosystem.config.js --env production
List the running processes:
pm2 list
The output is shown here without the table borders:
id name mode pid uptime restarts status cpu mem
0 myapp cluster 5402 4s 0 online 0% 52.1mb
1 myapp cluster 5409 4s 0 online 0% 51.8mb
You see one online process per CPU core. Send a few requests and notice that the pid in the response changes as PM2 distributes them across workers:
for i in 1 2 3 4; do curl -s http://127.0.0.1:3000/; echo; done
{"message":"Hello from Express","pid":5402}
{"message":"Hello from Express","pid":5409}
{"message":"Hello from Express","pid":5402}
{"message":"Hello from Express","pid":5409}
PM2 writes each worker's output to ~/.pm2/logs/. Read recent lines with:
pm2 logs myapp --lines 20
Log files grow without limit by default. Install the PM2 log rotation module, which rotates them daily and when they reach 10 MB:
pm2 install pm2-logrotate
Step 5 - Starting PM2 at boot and reloading without downtime
Save the current process list so PM2 can restore it after a reboot:
pm2 save
Leave the nodeapp shell to return to your sudo user:
exit
Generate and enable a systemd unit that starts PM2 as nodeapp at boot:
sudo pm2 startup systemd -u nodeapp --hp /home/nodeapp
The command creates /etc/systemd/system/pm2-nodeapp.service and enables it. Check its state:
systemctl is-enabled pm2-nodeapp.service
enabled
After a reboot, sudo -iu nodeapp pm2 list should show the application online again.
To deploy a change without dropping requests, use pm2 reload instead of restart. PM2 replaces workers one at a time: it starts a new worker, waits for its ready signal, then sends SIGINT to the old one. Test it as nodeapp:
sudo -iu nodeapp pm2 reload myapp
[PM2] Applying action reloadProcessId on app [myapp](ids: [ 0, 1 ])
[PM2] [myapp](0) ✓
[PM2] [myapp](1) ✓
Step 6 - Configuring Nginx as a reverse proxy
Install Nginx:
sudo apt install -y nginx
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/myapp
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
server {
listen 80;
listen [::]:80;
server_name app.your_domain;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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;
}
}
The map block sends the Upgrade headers only when the client asks for a WebSocket connection, so the configuration also works if you add Socket.IO or another WebSocket library later. Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Allow SSH and web traffic through UFW:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
From your own computer, check that the application answers through Nginx:
curl http://app.your_domain/health
{"status":"ok"}
Step 7 - Enabling HTTPS with Let's Encrypt
Install Certbot and its Nginx plugin, then request a certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.your_domain
Certbot installs the certificate in your server block and redirects HTTP to HTTPS. Verify it:
curl -I https://app.your_domain/health
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json; charset=utf-8
Renewal runs automatically through the certbot.timer unit. Test it with sudo certbot renew --dry-run.
Step 8 - Deploying updates
With the code in Git, a release consists of pulling the new code, installing dependencies and reloading the workers. Run it as nodeapp:
sudo -iu nodeapp bash -c 'cd ~/myapp && git pull && npm ci --omit=dev && pm2 reload ecosystem.config.js --env production'
Passing the ecosystem file to pm2 reload also applies any changes you made to it, such as new environment variables. Confirm all workers are online afterwards with sudo -iu nodeapp pm2 list.
Troubleshooting
A worker restarts in a loop. The restart counter (↺) in pm2 list keeps increasing. Read the error with sudo -iu nodeapp pm2 logs myapp --err --lines 50. Common causes are a missing dependency (run npm ci --omit=dev again) or a syntax error. Running node server.js directly as nodeapp shows the error in the terminal.
pm2 reload hangs and then restarts workers anyway. The new workers never sent the ready message within listen_timeout. Make sure process.send('ready') is called inside the listen callback.
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000. Another process uses the port, often an application you started manually with node server.js or under a different user's PM2. Find it with sudo ss -tlnp | grep 3000 and stop it.
Nginx returns 502 Bad Gateway. The application is not running or listens on a different port. Check sudo -iu nodeapp pm2 list and /var/log/nginx/error.log.
The application is not running after a reboot. Either pm2 save was not run after starting it, or the startup unit is missing. Check systemctl status pm2-nodeapp.service, start the application again as nodeapp and run pm2 save.
Conclusion
Your Express application now runs under PM2 in cluster mode as an unprivileged user, starts at boot, reloads without dropping requests and is served over HTTPS by Nginx.
As next steps, you can move secrets to a dedicated secrets store or environment file managed by your configuration tool, add rate limiting with the limit_req module in Nginx, and put an external uptime check on the /health endpoint.
