How to Run a Node.js App with PM2 Behind Nginx
To run a Node.js app in production on Ubuntu, install Node.js LTS from NodeSource, start your app with PM2 so it restarts on crashes and reboots (pm2 start, pm2 save, pm2 startup), and put Nginx in front as a reverse proxy that forwards requests to 127.0.0.1:3000 and serves HTTPS with a free Let's Encrypt certificate. This guide covers each step on a Cloudesta Cloud VPS or dedicated server running Ubuntu 24.04 (also valid on 22.04). It also covers environment variables, logs, zero-downtime deploys and the most common errors.
Prerequisites
- A server running Ubuntu 24.04 or 22.04 with root or sudo SSH access, such as a Cloudesta Cloud VPS.
- A domain (for example
example.com) with an A record pointing to your server IP (for example203.0.113.10). - Your app's code, including
package.jsonandpackage-lock.json. - A non-root user with sudo rights. PM2 will run the app as this user.
1. How do I install Node.js LTS on Ubuntu?
Ubuntu's own nodejs package is usually several versions behind, so install Node.js from the NodeSource repository. The setup_lts.x script tracks the current LTS line. To pin a specific major version, use a script such as setup_22.x instead.
sudo apt update
sudo apt install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
npm -v
2. How do I deploy the app to /var/www/app?
Create the directory and give your user ownership of it. Then clone or upload your code into it.
sudo mkdir -p /var/www/app
sudo chown -R $USER:$USER /var/www/app
cd /var/www/app
git clone https://github.com/youruser/yourrepo.git .
npm ci --omit=dev
npm ci installs the exact versions listed in package-lock.json and fails if that file is missing. This makes deploys repeatable. If your app has a build step, such as TypeScript or Next.js, run a plain npm ci followed by npm run build instead.
Make sure the app listens on localhost only. Nginx will handle public traffic. For example, in Express:
const port = process.env.PORT || 3000;
app.listen(port, '127.0.0.1', () => console.log(`Listening on ${port}`));
3. How do I keep a Node.js app running with PM2?
Install PM2 globally and start the app under a clear name:
sudo npm install -g pm2
cd /var/www/app
pm2 start app.js --name app
pm2 status
To make PM2 start on boot, generate a systemd unit. Run the command below as your normal user, not with sudo:
pm2 startup systemd
PM2 prints a line that starts with sudo env PATH=.... Copy that exact line and run it. It looks similar to this:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u youruser --hp /home/youruser
Next, save the current process list. PM2 restores this list at boot:
pm2 save
pm2 save again every time you add or remove apps. PM2 restores only what was saved.4. How do I set environment variables for a PM2 app?
The cleanest option is a PM2 ecosystem file. Create /var/www/app/ecosystem.config.js:
module.exports = {
apps: [{
name: 'app',
script: 'app.js',
cwd: '/var/www/app',
exec_mode: 'cluster',
instances: 2,
env_production: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
Replace the process you started earlier with one that uses this file:
pm2 delete app
pm2 start ecosystem.config.js --env production
pm2 save
For secrets such as API keys, you can keep a .env file instead and load it in your code. Install the loader with npm install dotenv, then add require('dotenv').config() as the first line of app.js. Restrict the file with chmod 600 .env and never commit it to git. After you change variables, run pm2 restart app --update-env so the app picks them up.
5. How do I configure Nginx as a reverse proxy for Node.js?
Install Nginx and allow web traffic through the firewall. Allow OpenSSH first so you do not lock yourself out:
sudo apt install -y nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Create /etc/nginx/sites-available/app. The map block and the Upgrade/Connection headers are what make WebSockets work:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
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;
proxy_read_timeout 300s;
}
}
Enable the site, disable the default site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
If you use Express, add app.set('trust proxy', 1) so the app sees real client IPs and the correct protocol behind Nginx.
6. How do I add a free SSL certificate with Let's Encrypt?
Once DNS points to the server, run:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
Certbot adds the HTTPS configuration to your server block, sets up the HTTP-to-HTTPS redirect, and installs a systemd timer that renews the certificate automatically.
7. How do I view logs and deploy updates without downtime?
View live logs, or the last 100 lines, with:
pm2 logs app
pm2 logs app --lines 100
PM2 log files grow forever unless you rotate them. Install the rotation module once:
pm2 install pm2-logrotate
A typical deploy looks like this:
cd /var/www/app
git pull
npm ci --omit=dev
pm2 reload ecosystem.config.js --env production
pm2 reload gives zero downtime only in cluster mode (exec_mode: 'cluster' with two or more instances), because it restarts workers one at a time. In fork mode, reload behaves like pm2 restart and causes a brief interruption.
Verify it works
pm2 status
curl -I http://127.0.0.1:3000
curl -I https://example.com
systemctl status pm2-$USER
Check that all four commands succeed:
pm2 statusshows the app asonline.- Both
curlcommands return an HTTP status such as200. - The systemd unit shows as
enabled.
Finally, run sudo reboot. After the server comes back up, confirm that pm2 status lists the app again without you starting it manually.
Common problems
Error: listen EADDRINUSE :::3000
Another process already holds the port. This is often an old copy of the app started with plain node. Find it with sudo ss -ltnp | grep :3000, stop it, then run pm2 restart app. Also check pm2 list for duplicate entries and remove extras with pm2 delete.
The app does not come back after a reboot
Either pm2 save was never run, or pm2 startup was run for the wrong user, such as root. Run pm2 unstartup systemd, then repeat step 3 as the user that owns the app, and finish with pm2 save.
502 Bad Gateway
Nginx cannot reach the app. Run sudo tail -n 50 /var/log/nginx/error.log. If you see connect() failed (111: Connection refused), the app is crashed or listening on a different port. Check pm2 logs app and make sure proxy_pass matches the app's PORT.
WebSockets fail or disconnect every 60 seconds
Check two things:
- The
mapblock,proxy_http_version 1.1and theUpgrade/Connectionheaders must all be present. Certbot keeps them, but hand edits sometimes drop them. - Raise
proxy_read_timeoutif connections close after a period of inactivity.
If you use Socket.IO with long-polling in cluster mode, either enable sticky sessions or set instances: 1.
Frequently asked questions
Should I run PM2 as root?
No. Run it as a normal user that owns /var/www/app, so a bug in the app cannot take over the whole server. Nginx listens on ports 80 and 443, so the app never needs root privileges.
Can I host several Node.js apps on one server?
Yes. Give each app its own port and PM2 name, and create a separate Nginx server block for each domain. Then run pm2 save once all apps are running.
What is the difference between pm2 restart and pm2 reload?
restart stops and starts all processes at once, which causes a short outage. reload replaces cluster workers one at a time, so the app stays available during the update.
Need help? If you get stuck or need something only we can change on your server, such as rDNS, rescue mode, an OS reinstall or a plan upgrade, open a ticket at https://cloudesta.com/submitticket.php. You can also browse more guides in the Cloudesta knowledgebase.