PM2 + Nginx Reverse Proxy: Connect a Node.js App to Port 80
On an Ubuntu 24.04 server, we set up a Node.js PM2 Nginx reverse proxy so that PM2 runs the app and Nginx passes requests arriving on port 80 to the app. We also confirmed that PM2 starts the app again even after the app process is killed or the server is rebooted.
The result boxes below are output from commands run as the regular user deploy. The domain is the example value example.com, and requests were sent from inside the server with a Host header.
1️⃣ Install Node.js
Node.js is installed by adding the NodeSource repository to apt, and we chose Node.js 26, the LTS version as of November 2026.
🔲 NodeSource setup_26.x
export DEBIAN_FRONTEND=noninteractiveSetting this value first keeps a configuration prompt from appearing during package installation. The -E option of the sudo command below passes this value on to the command that runs with root privileges.
curl -fsSL https://deb.nodesource.com/setup_26.x | sudo -E bash -2026-11-07 16:08:16 - Repository configured successfully.
2026-11-07 16:08:16 - To install Node.js, run: apt install nodejs -yOnce the script finishes adding the repository, you can install Node.js with apt like any other Ubuntu package.
🔲 apt-get install nodejs
sudo -E apt-get install -y nodejsSetting up libatomic1:amd64 (14.2.0-4ubuntu2~24.04.1) ...
Setting up nodejs (26.13.0-1nodesource1) ...
Processing triggers for libc-bin (2.39-0ubuntu8.9) ...
Processing triggers for man-db (2.12.0-4build2) ...🔲 node -v, npm -v
node -vv26.13.0npm -v11.20.0npm comes with the nodejs package, so you don't need to install it separately.
2️⃣ Node.js test app
To check that PM2 and Nginx are connected properly, create a test app that returns one line of text when it receives a request.
🔲 app.js
mkdir -p ~/helloCreate a hello folder under your home folder and write an app.js file inside it.
cat > ~/hello/app.js <<'APP'
const http = require('http');
http.createServer((req, res) => {
console.log(req.method, req.url);
res.end('hello from node\n');
}).listen(3000, '127.0.0.1');
APPThe app only accepts requests on port 3000 of 127.0.0.1, so it can't be opened directly from outside the server. Nginx will be set up to receive outside requests and pass them to this port.
3️⃣ Run the app with PM2 and auto start
PM2 is a process manager that runs Node.js apps in the background and starts an app again when its process stops. This section also sets up auto start, which brings the app back up when the server boots.
🔲 npm install -g pm2
sudo npm install -g pm2added 45 packages in 5spm2 -v[PM2] Spawning PM2 daemon with pm2_home=/home/deploy/.pm2
[PM2] PM2 Successfully daemonized
7.1.2Running a pm2 command for the first time also starts the PM2 daemon, and the installed PM2 is version 7.1.2.
🔲 pm2 start
cd ~/helloMove to the folder that contains app.js, then run the app with PM2.
pm2 start app.js --name hello[PM2] Starting /home/deploy/hello/app.js in fork_mode (1 instance)
[PM2] Done.
┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 17257 │ 0s │ 0 │ online │ 0% │ 26.3mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘--name is the app name shown in the PM2 list, and later commands refer to the app by this name.
pm2 list┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 17257 │ 2s │ 0 │ online │ 0% │ 58.5mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
host metrics | cpu: 0% | ram usage: 38.8% | disk: ⇓ 0mb/s ⇑ 0mb/sonline in the status column means the app is running, and the ↺ column is the number of times PM2 has restarted the app.
🔲 curl 127.0.0.1:3000
When you send a request from inside the server, the line of text the app returns comes back as is.
curl -sS http://127.0.0.1:3000/hello from node🔲 pm2 restart
After changing the app code, restart the app to apply the change.
pm2 restart helloUse --update-env to update environment variables
[PM2] Applying action restartProcessId on app [hello](ids: [ 0 ])
[PM2] [hello](0) ✓
┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 17306 │ 0s │ 1 │ online │ 0% │ 19.3mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
host metrics | cpu: 0% | ram usage: 38.8% | disk: ⇓ 0mb/s ⇑ 0mb/spm2 list┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 17306 │ 2s │ 1 │ online │ 0% │ 58.3mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
host metrics | cpu: 0% | ram usage: 38.8% | disk: ⇓ 0mb/s ⇑ 0mb/sAfter the restart, the pid changed from 17257 to 17306 and the ↺ column went up to 1.
🔲 kill -9
You can simulate the app process stopping suddenly with kill -9, and pm2 pid prints the app's process ID.
kill -9 "$(pm2 pid hello)"Check the PM2 list again 3 seconds later.
pm2 list┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 17797 │ 3s │ 2 │ online │ 0% │ 58.6mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
host metrics | cpu: 51.2% | ram usage: 35.8% | eth0: ⇓ 1kb/s ⇑ 1kb/s | eth1: ⇓ 19kb/s ⇑ 0mb/s | disk: ⇓ 728kb/s ⇑ 3.1mb/sPM2 had started the app again with the new pid 17797 and it was online, and the ↺ column had gone up to 2.
🔲 pm2 save
pm2 save[PM2] Saving current process list...
[PM2] Successfully saved in /home/deploy/.pm2/dump.pm2pm2 save stores the list of currently running apps in the dump.pm2 file, and at boot PM2 starts the apps again from this list.
🔲 pm2 startup
To have PM2 run at boot, register PM2 as a systemd service. The command below creates the service with the current account name and home folder.
sudo env PATH="$PATH" pm2 startup systemd -u "$(id -un)" --hp "$HOME"ExecStart=/usr/lib/node_modules/pm2/bin/pm2 resurrect
ExecReload=/usr/lib/node_modules/pm2/bin/pm2 reload all
ExecStop=/usr/lib/node_modules/pm2/bin/pm2 kill
[Install]
WantedBy=multi-user.target
Target path
/etc/systemd/system/pm2-deploy.service
Command list
[ 'systemctl enable pm2-deploy' ]
[PM2] Writing init configuration in /etc/systemd/system/pm2-deploy.service
[PM2] Making script booting at startup...
[PM2] [-] Executing: systemctl enable pm2-deploy...
Created symlink /etc/systemd/system/multi-user.target.wants/pm2-deploy.service → /etc/systemd/system/pm2-deploy.service.
[PM2] [v] Command successfully executed.env PATH is the part that lets pm2, run with root privileges, find the node path of the current account, and -u and --hp are the account and home folder the service runs as. The ExecStart line in the service file is pm2 resurrect, which restores the saved app list at boot.
systemctl status pm2-deploy --no-pager | head -n 5○ pm2-deploy.service - PM2 process manager
Loaded: loaded (/etc/systemd/system/pm2-deploy.service; enabled; preset: enabled)
Active: inactive (dead)
Docs: https://pm2.keymetrics.io/enabled means the service runs at boot. pm2 startup only registers the service and doesn't start it now, so it shows inactive (dead), and the service runs from the next boot.
🔲 pm2 list after reboot
sudo systemctl rebootAbout 2 minutes later, connect again as deploy and check the app status.
uptime -pup 2 minutespm2 list┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ hello │ default │ N/A │ fork │ 976 │ 108s │ 0 │ online │ 0% │ 63.0mb │ deploy │ disabled │
└────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
host metrics | cpu: 0.3% | ram usage: 35.1% | disk: ⇓ 4kb/s ⇑ 4kb/sTwo minutes after the server came up, the app had already been online for 108 seconds, as PM2 had started the app again along with the boot.
4️⃣ Nginx reverse proxy setup
Since the app only accepts requests on 127.0.0.1:3000, Nginx receives requests coming in on port 80 and passes them to the app. A setup like this, where a server in front receives requests and forwards them to a server behind it, is called a reverse proxy.
🔲 apt-get install nginx
sudo -E apt-get install -y nginxSetting up nginx-common (1.24.0-2ubuntu7.19) ...
Created symlink /etc/systemd/system/multi-user.target.wants/nginx.service → /usr/lib/systemd/system/nginx.service.
Setting up nginx (1.24.0-2ubuntu7.19) ...nginx -vnginx version: nginx/1.24.0 (Ubuntu)The Nginx in the Ubuntu 24.04 repository is 1.24.0, and installing it also registers it to run at boot.
🔲 /etc/nginx/sites-available/hello
Create the /etc/nginx/sites-available/hello file and put the following content in it.
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}proxy_pass is the app address that requests are passed to. The Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers are values passed along so the app can know the domain of the original request, the visitor's IP, and whether it was http or https.
proxy_http_version 1.1 and the Upgrade and Connection headers are needed to pass WebSocket connections through to the app. The Upgrade and Connection headers are not forwarded to the app once they pass through a proxy, so they are added directly in the configuration.
sudo ln -sf /etc/nginx/sites-available/hello /etc/nginx/sites-enabled/helloNginx only reads configurations that have a link in the sites-enabled folder.
🔲 nginx -t
sudo nginx -tnginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulsudo systemctl reload nginxCheck the configuration syntax first with nginx -t, and if it passes, apply the new configuration with reload without stopping Nginx.
🔲 curl request with a Host header
curl -sS -i -H 'Host: example.com' http://127.0.0.1/ | head -n 4HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sat, 07 Nov 2026 08:09:38 GMT
Content-Length: 16After the configuration was reloaded, a request sent with a Host header got the app's response back. The Server line in the response headers is nginx, and Content-Length 16 is the same size as the hello from node line the app returns (16 bytes including the newline). To connect from outside the server, port 80 must be open in the firewall.
5️⃣ Apply HTTPS with Certbot
With Certbot's Nginx plugin, you can issue a certificate and change the Nginx configuration in one go. Run the command below while the domain points to the server IP.
sudo certbot --nginxCertbot installation and the automatic renewal test are covered in the post below; the issuing command in that post is for Apache, so replace only the issuing step with the command above.
🔢 FAQ & Recommended Content
Service logs
journalctl command: usage, logs by service and cleaning up journal size
A post on how to read logs per service for systemd services such as nginx and pm2-deploy.
ℹ️ Affiliate Disclosure
This site's content contains affiliate links. When a visitor buys a product or service through one of them, the site receives a commission from the seller. The amount the buyer pays(it goes down during event discounts ↓)does not go up. Posted prices, discounts, and stock reflect the time of writing and may differ, so confirm with the seller before buying. Products are chosen and reviewed by our own standards, and commissions do not affect the order or content of reviews.