How To Deploy a Node.js App With PM2 and Nginx on Ubuntu
Tricknowtech Team 11 min read
Goal
In this tutorial, you will install Node.js from NodeSource's official apt repository, run a Node.js application persistently with PM2 (including automatic restart on crash and on server reboot), and configure Nginx as a reverse proxy so the app is reachable over the standard HTTP port without its internal port ever being exposed to the internet.
Prerequisites
An Ubuntu server (a recent LTS release) with a non-root sudo user
ufw enabled with OpenSSH already allowed, so the firewall step doesn't lock you out
A domain name with its DNS A record pointed at your server's public IP (example.com is used throughout)
Basic familiarity with the command line and a terminal text editor
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
PM2 keeps a Node.js process running in the background, restarts it automatically if it crashes, and can bring it back up after a server reboot. Nginx sits in front of it as a reverse proxy, forwarding requests from the standard HTTP port to the app's internal port. Running the two together is the standard way to put a Node.js app into production on a Linux server: the app never listens on a public port directly, and you get process supervision and log management handled by tools built for the job instead of ad hoc shell scripts. (Nginx is also the piece you'd configure for TLS/HTTPS once you're ready for it — that's a separate step, not covered in this tutorial.)
Prerequisites
Before you begin, you'll need:
An Ubuntu server (a recent LTS release) with a non-root user that has sudo privileges
ufw enabled with OpenSSH already allowed (sudo ufw allow OpenSSH) so you don't lock yourself out when you touch the firewall in Step 6
A registered domain name with its DNS A record pointed at your server's public IP address — this tutorial uses example.com
Basic comfort with the command line and a text editor (nano or vim)
Step 1 — Install Node.js
Ubuntu's default apt repository ships an old Node.js version that lags well behind current LTS releases. Install from NodeSource's official apt repository instead — this is the setup method documented on nodejs.org itself, and it installs a current, actively supported version:
The -E flag tells sudo to preserve your existing shell environment variables (such as PATH and any proxy settings) while running the script as root, instead of resetting to a minimal root environment — that's standard practice when piping an installer script into sudo, so the script and the apt install that follows behave the way they would in your own shell. Verify the install:
bash
node -v
npm -v
Both commands should print version numbers. If node -v fails with "command not found," the apt install step above did not complete — re-run it and check for errors in its output.
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
If you already have an app to deploy, skip to Step 3. Otherwise, create a small HTTP server to deploy through the rest of this tutorial. Bind it to 127.0.0.1 rather than 0.0.0.0 — that way the app is only reachable from the same machine, and Nginx is the only thing that can reach it from outside. The app's port never needs to be opened in the firewall.
bash
mkdir ~/myapp && cd ~/myapp
Save the following as ~/myapp/app.js:
text
const http = require('http');
const PORT = 3000;
const HOST = '127.0.0.1';
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello from Node.js\n');
});
server.listen(PORT, HOST, () => {
console.log(`Server running at http://${HOST}:${PORT}/`);
});
Step 3 — Install PM2 and start the app
Install PM2 globally with npm:
bash
sudo npm install -g pm2
Start the app under PM2's supervision:
bash
pm2 start app.js --name myapp
If your app is defined by a package.json start script instead of a single entry file, start it this way instead:
bash
pm2 start npm --name myapp -- start
Confirm it's running and check its output:
bash
pm2 list
pm2 logs myapp
pm2 list should show myapp with status online. pm2 logs streams live output — press Ctrl+C to exit it (this only stops the log stream, not the app). A few other commands you'll use regularly:
pm2 restart myapp — restart after deploying new code
pm2 stop myapp — stop without removing it from PM2's list
pm2 delete myapp — remove it from PM2 entirely
Step 4 — Start PM2 automatically on boot
By default, PM2's process list doesn't survive a server reboot. Generate a startup script:
bash
pm2 startup
“pm2 startup prints a command tailored to your system — something like `sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u <your-user> --hp /home/<your-user>`. The exact path and username depend on your machine. Copy the line PM2 prints in your own terminal and run that, rather than typing out an example from memory.”
After running the command PM2 gave you, snapshot the current process list so it's restored on boot:
bash
pm2 save
Any app you later start with pm2 start (and then pm2 save again) will also come back after a reboot. If you stop or delete an app and want that reflected after reboot too, run pm2 save again to update the snapshot.
Step 5 — Configure Nginx as a reverse proxy
Install Nginx if it isn't already present:
bash
sudo apt update
sudo apt install -y nginx
Create a new server block file for your domain:
bash
sudo nano /etc/nginx/sites-available/example.com
Add the following, replacing example.com with your actual domain. This forwards incoming requests to the app on its loopback-only port and passes along the headers the app needs to see the real client IP and protocol:
Enable the site by symlinking it into sites-enabled, then test the configuration before reloading — nginx -t catches typos before they take down the running config:
Allow web traffic through ufw using the Nginx Full profile, which opens ports 80 and 443 (443 is ready for when you add TLS later; ufw does nothing about TLS itself). The app's own port, 3000, stays closed to the outside world in both cases, since it only ever listens on 127.0.0.1:
bash
sudo ufw allow 'Nginx Full'
sudo ufw status
The status output should list Nginx Full as ALLOW alongside OpenSSH — don't remove the OpenSSH rule, or you'll lose your SSH session.
Step 7 — Verify the deployment
Check each layer independently, starting from the app itself and working out to the public domain:
bash
pm2 list
curl http://127.0.0.1:3000
pm2 list should show myapp as online. curl should return "Hello from Node.js" (or your own app's response) directly from the server, confirming the app itself works regardless of Nginx. Finally, visit http://example.com from a browser — if that also returns the app's response, the full path (browser → Nginx → PM2-managed app) is working end to end.
Troubleshooting: 502 Bad Gateway
A 502 from Nginx means Nginx itself is reachable and working, but it couldn't get a response from the address in proxy_pass. This almost always means the Node.js process isn't there to answer — it crashed, was never started, or isn't listening on the port/interface Nginx is pointed at. Check PM2 first:
bash
pm2 list
pm2 logs myapp --lines 50
If pm2 list shows myapp as errored or stopped instead of online, the app crashed on startup — pm2 logs myapp will show the actual error (a missing dependency, a syntax error, a port already in use, etc.). If it shows online but you still get 502s, double-check that the app is listening on the exact host and port your server block's proxy_pass targets — a mismatch (for example the app listening on a different port than 3000, or bound only to an IPv6 address while Nginx connects over IPv4) will produce this same error even though PM2 reports the process as healthy.