Deploy a Node.js app with PM2 and nginx on Ubuntu 24.04
Run a Node.js app under its own user with PM2, bring it back after reboots, put nginx in front as a reverse proxy, and ship updates without downtime.
Tested on Ubuntu 24.04 LTS
Before you start
- A server running Ubuntu 24.04
- Root or a user with sudo
- A domain name pointed at the server (for HTTPS)
- Your app's code in a Git repository
To deploy a Node.js app on Ubuntu 24.04, run it as an unprivileged user under PM2 (which restarts it when it crashes and starts it at boot), have it listen on 127.0.0.1 only, and put nginx in front on ports 80 and 443. This tutorial does each step with an Express app as the example, then shows how to ship an update with a zero-downtime reload.
The same steps work for any Node.js server: Express, Fastify, NestJS, or the standalone output of Next.js and Nuxt. Only the start file changes.
| What | In this tutorial |
|---|---|
| The app's user | deploy |
| The app's directory | /home/deploy/myapp |
| The port the app listens on | 3000, on 127.0.0.1 only |
| The domain | app.example.com |
Install Node.js
Install the current LTS release of Node.js from NodeSource's repository:
curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install -y nodejs gitCheck it worked:
node -v
npm -vOn 21 October 2026 this installed Node.js v24.22.1 and npm 11.19.1. Ubuntu's own nodejs package is Node.js 18, which the Node.js project no longer supports, so don't use it for a new app. How to install Node.js on Ubuntu 24.04 compares NodeSource, nvm and Ubuntu's package if you need a specific version.
Install PM2
PM2 is a process manager for Node.js: it keeps the app running, restarts it if it crashes, collects its logs and starts it again after a reboot. Install it globally:
sudo npm install -g pm2
pm2 -vThis tutorial was tested with PM2 7.1.0.
Create a user for the app
Don't run the app as root. If someone finds a hole in it, they get only what this user can touch:
sudo adduser --disabled-password --gecos "" deploy
sudo -iu deployThe rest of the app steps run as deploy. You'll switch back to your sudo user for nginx.
Put the code on the server
As deploy, clone your repository and install its production dependencies:
git clone https://github.com/your-name/myapp.git ~/myapp
cd ~/myapp
npm ci --omit=devnpm ci installs exactly what package-lock.json lists, which is what you want on a server. If your app has a build step (TypeScript, Next.js), run npm ci without --omit=dev, then npm run build.
If you don't have an app yet and want to follow along, make a minimal Express one:
mkdir -p ~/myapp && cd ~/myapp
npm init -y
npm install express// ~/myapp/server.js
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (req, res) => res.send("Hello from Node.js " + process.version + "\n"));
app.listen(port, "127.0.0.1", () => console.log("listening on 127.0.0.1:" + port));Two details matter on a server. The app reads its port from PORT, so PM2 can set it. And it listens on 127.0.0.1, not on every address: only nginx on the same server can reach it, so nobody bypasses the proxy on port 3000. In your own app, find the listen() call and give it "127.0.0.1" as the host.
Describe the app in an ecosystem file
PM2 can start an app from the command line, but an ecosystem file keeps the settings in your repository. Create ~/myapp/ecosystem.config.cjs:
module.exports = {
apps: [
{
name: "myapp",
script: "server.js",
cwd: "/home/deploy/myapp",
instances: 1,
env: {
NODE_ENV: "production",
PORT: 3000,
},
max_memory_restart: "300M",
},
],
};instances: 1runs the app in PM2's cluster mode with one process, which is what letspm2 reloadswap it without dropping requests. Set"max"for one process per CPU core, but only if your app keeps no state in memory (sessions, caches) that the processes would need to share.max_memory_restartrestarts the app if it grows past 300 MB, a safety net for slow memory leaks. Size it to your plan's memory.- The
.cjsextension makes it load whether or not yourpackage.jsonsays"type": "module".
Keep secrets such as database passwords out of this file if it's in Git. Put them in a .env file the app reads, readable by deploy only (chmod 600 .env).
Start the app with PM2
cd ~/myapp
pm2 start ecosystem.config.cjs
pm2 lsCheck it worked: pm2 ls shows myapp with the status online, and the app answers on its local port:
curl http://127.0.0.1:3000Hello from Node.js v24.22.1If the status is errored or the restart counter (↺) keeps climbing, read the app's output with pm2 logs myapp --lines 50.
Start it again after a reboot
Save the list of running apps, then ask PM2 for its startup command:
pm2 save
pm2 startup systemd -u deploy --hp /home/deployPM2 prints a command that starts with sudo env PATH=$PATH:/usr/bin pm2 startup systemd. Leave the deploy shell (exit) and run that exact command as your sudo user. It writes a systemd service called pm2-deploy that runs pm2 resurrect at boot, which restarts everything you saved.
Check it worked:
systemctl status pm2-deployIt should be enabled. Run pm2 save again whenever you add or remove an app, or the next boot brings back the old list.
Put nginx in front
As your sudo user, install nginx:
sudo apt install -y nginxCreate /etc/nginx/sites-available/myapp:
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
upstream myapp {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://myapp;
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 $connection_upgrade;
}
}- The
mapand theUpgrade/Connectionheaders let WebSockets (Socket.IO, live reload, GraphQL subscriptions) through. For ordinary requests theConnectionheader is left empty, so nginx can keep connections to the app open (keepalive 16) instead of opening one per request. - The
X-Forwarded-*headers tell the app the visitor's real address and that the request came in over HTTPS. In Express, addapp.set("trust proxy", "loopback")soreq.ipandreq.secureuse them. mapis allowed once per variable name across all sites. If you proxy a second app, leave themapblock out of its file.
Enable the site, drop the default one and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxCheck it worked: nginx -t says syntax is ok and test is successful, and the app answers through nginx:
curl -H "Host: app.example.com" http://127.0.0.1/Open the firewall and add HTTPS
Allow web traffic in the server's firewall if ufw is active:
sudo ufw allow "Nginx Full"On VPSNine, a server with a panel firewall also needs rules for TCP 80 and 443. Don't open port 3000: the app is only for nginx.
Then get a free certificate with Certbot's nginx plugin. Serve a site with nginx and HTTPS has the DNS records, the certbot --nginx command and how renewals work; it edits the server block above for you and adds the redirect from HTTP.
Check it worked: https://app.example.com loads your app with a padlock.
Ship an update without downtime
As deploy, pull the new code, install dependencies and reload:
cd ~/myapp
git pull
npm ci --omit=dev
pm2 reload myapppm2 reload starts a new process, waits for it to come up and only then stops the old one, so requests keep being answered. pm2 restart stops first and starts after, which drops requests for a moment. If you changed the ecosystem file itself, run pm2 reload ecosystem.config.cjs --update-env so PM2 picks up the new settings, then pm2 save.
Check it worked: pm2 ls shows the app online with a fresh uptime, and curl http://127.0.0.1:3000 returns the new version.
Keep the logs in check
PM2 writes the app's output to ~/.pm2/logs/ and never trims it. Its log rotation module caps the files:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7Read the latest lines at any time with pm2 logs myapp --lines 100, or watch CPU and memory per process with pm2 monit.
Troubleshooting
502 Bad Gateway from nginx. nginx can't reach the app. /var/log/nginx/error.log shows:
connect() failed (111: Connection refused) while connecting to upstream, ... upstream: "http://127.0.0.1:3000/"The app isn't running or listens on another port. Check pm2 ls and pm2 logs myapp, and that PORT in the ecosystem file matches the upstream block.
The app doesn't come back after a reboot, and pm2 ls is empty. The boot service ran but nothing was saved, or it runs as another user. Start the app as deploy, run pm2 save as deploy, and check the service is pm2-deploy (not pm2-root).
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000. Another process holds the port, often a copy you started by hand with node server.js. Find it with sudo ss -ltnp 'sport = :3000' and stop it.
WebSockets fail to connect through nginx (the browser console shows the handshake failing), while normal pages work. The Upgrade and Connection headers aren't being passed. Check the two proxy_set_header lines and the map block are in the config, then sudo nginx -t && sudo systemctl reload nginx.
Next steps
- Run several apps on one server: give each its own port and
serverblock, or use Caddy as a reverse proxy for several apps. - Add a database: Install PostgreSQL on Ubuntu 24.04 or Redis for sessions and caches.
- Watch the server's CPU, memory and disk: Monitor your server.
- Prefer containers? Run a multi-container app with Docker Compose.