PM2 + Nginx Reverse Proxy: Connect a Node.js App to Port 80

Test environmentDebian familyUbuntu 24.04.5 LTSregular user account
Node.js app operation and reverse proxy setup verified

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.

🎯 Key points
🗂️ Request flow
📂 NginxReverse proxy
📄 CertbotHTTPS certificate
📂 PM2Running the app and auto start
📄 Node.js app
Running the app with PM2 and starting it at boot
Nginx passes port 80 requests to the app
Prerequisites
ServerUbuntu 24.04 with initial setup donesudo privileges for a regular user accountDomain needed for HTTPS

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=noninteractive

Setting 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 -y

Once 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 nodejs
Setting 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 -v
v26.13.0
npm -v
11.20.0

npm 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 ~/hello

Create 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');
APP

The 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 pm2
added 45 packages in 5s
pm2 -v
[PM2] Spawning PM2 daemon with pm2_home=/home/deploy/.pm2
[PM2] PM2 Successfully daemonized
7.1.2

Running a pm2 command for the first time also starts the PM2 daemon, and the installed PM2 is version 7.1.2.

🔲 pm2 start

cd ~/hello

Move 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/s

online 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 hello
Use --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/s
pm2 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/s

After 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/s

PM2 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.pm2

pm2 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 reboot

About 2 minutes later, connect again as deploy and check the app status.

uptime -p
up 2 minutes
pm2 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/s

Two 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 nginx
Setting 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 -v
nginx 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/hello

Nginx only reads configurations that have a link in the sites-enabled folder.

🔲 nginx -t

sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx

Check 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 4
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sat, 07 Nov 2026 08:09:38 GMT
Content-Length: 16

After 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.

🧭 Node.js app requests and restarts
Explainer diagram
🧭 Scenario
Request flow
🌐Browserexample.com request
➜
🧱NginxPort 80, proxy_pass
➜
🟢Node.js app127.0.0.1:3000
Process management
⚙️systemdpm2-deploy service
➜
🔁PM2dump.pm2 app list
➜
🟢Node.js apphello
Nginx on port 80 receives the browser request, passes it to 127.0.0.1:3000 set in proxy_pass, and returns the app's response as is.
When the app process stops, PM2 starts the same app again right away. After stopping it with kill -9 and checking pm2 list 3 seconds later, the app was online with a new pid.
When the server boots, systemd runs the pm2-deploy service, and PM2 starts the apps again from the list saved with pm2 save.

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 --nginx

Certbot 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

When Nginx can't connect to the app address set in proxy_pass, it returns 502 Bad Gateway. After the app was taken down with pm2 stop hello, the same request returned 502, and /var/log/nginx/error.log had a [connect() failed (111: Connection refused) while connecting to upstream] line along with the address it tried to reach, http://127.0.0.1:3000/. Start by checking in pm2 list that the app is online, and that the port the app listens on matches the proxy_pass address. After the app was restarted, the same request came back with 200.

PM2 saves the app's output as files named after the app in the .pm2/logs folder under the home folder of the account that ran it. Running pm2 logs hello --nostream --lines 5 showed the last lines of two files, /home/deploy/.pm2/logs/hello-out.log and hello-error.log, and the out file contained the GET / request the app logged. Without --nostream, it keeps printing new log lines as they come in.

The auto start service is removed with pm2 unstartup systemd. After adding or removing apps, run pm2 save again so the apps run from the changed list at boot. When pm2 startup was run, these two commands were also shown at the end of its output.

ℹ️ 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Prove your humanity: 3   +   3   =