Blog/How to Deploy a Node.js App in Production with PM2 and Nginx (Ubuntu, Startup Script, and Zero-Downtime Reloads)

NginxNode.jsPM2LinuxDevOps··greencrew

How to Deploy a Node.js App in Production with PM2 and Nginx (Ubuntu, Startup Script, and Zero-Downtime Reloads)

To run a Node.js app in production, start it with pm2 start app.js --name myapp, make it survive reboots with pm2 startup and pm2 save, and put Nginx in front with proxy_pass http://127.0.0.1:3000. This guide covers installing Node.js, an ecosystem.config.js file, cluster mode, zero-downtime reloads, logs, and HTTPS.

Short answer: Don't run node app.js in an SSH session and hope for the best. Install Node.js from an LTS release, run the app with PM2 (pm2 start app.js --name myapp), then run pm2 startup and pm2 save so it restarts after crashes and reboots. Bind the app to 127.0.0.1:3000 and put Nginx in front with proxy_pass http://127.0.0.1:3000;, so Nginx handles ports 80/443, TLS, and static files. Ship new code with pm2 reload myapp so users don't see downtime.

Why You Need PM2 and Nginx for Node.js in Production

A Node.js process is a single program. If it throws an uncaught exception, it exits. If the server reboots, nothing starts it again. And if you bind it to port 80 directly, it has to run as root. A production setup fixes all three:

Problemnode app.js aloneWith PM2 + Nginx
App crashesSite is down until you noticePM2 restarts it automatically
Server rebootsApp doesn't startpm2 startup registers a systemd service
Ports 80/443Needs root or extra capabilitiesNginx listens; Node stays on a high port as a normal user
HTTPSCertificate handling in app codeNginx terminates TLS with Certbot certificates
Multiple CPU coresOne core usedPM2 cluster mode runs one process per core
DeploysKill and restart, dropped requestspm2 reload restarts processes one at a time
LogsLost when the terminal closesWritten to ~/.pm2/logs/

PM2 is a process manager for Node.js. It isn't the only option (a plain systemd unit works too, compared below), but it's the most common because it adds clustering, reloads, and log handling with very little setup.

Prerequisites

  • An Ubuntu server (24.04 or 22.04) with a sudo user. Don't run the app as root.
  • Nginx installed (how to install Nginx on Ubuntu)
  • A domain pointed at the server if you want HTTPS

Step 1: Install Node.js (LTS)

Ubuntu's own nodejs package often lags behind the current LTS release. Install from NodeSource instead:

curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install nodejs
node -v
npm -v

setup_lts.x tracks the current LTS line. To pin a major version, use its script instead (for example setup_22.x). Check the Node.js release schedule and stick to LTS versions in production.

Alternative: nvm installs Node per user and makes switching versions easy. It works with PM2, but note that pm2 startup records the Node path at the time you run it, so re-run it after you switch versions.

Step 2: Prepare the Application

Clone your app into a directory owned by your deploy user and install production dependencies:

sudo mkdir -p /var/www/myapp
sudo chown $USER:$USER /var/www/myapp
git clone https://github.com/you/myapp.git /var/www/myapp
cd /var/www/myapp
npm ci --omit=dev

npm ci installs exactly what's in package-lock.json. Building TypeScript or a frontend usually needs dev dependencies, so in that case run npm ci, then npm run build, then npm prune --omit=dev.

Make the app listen on localhost only, so the only way in is through Nginx:

// app.js
const express = require('express');
const app = express();

app.set('trust proxy', 'loopback'); // trust X-Forwarded-* from Nginx

app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/', (req, res) => res.send('Hello from Node.js behind Nginx'));

const port = process.env.PORT || 3000;
app.listen(port, '127.0.0.1', () => console.log(`Listening on ${port}`));

trust proxy makes Express read the real client IP and protocol from Nginx's X-Forwarded-For and X-Forwarded-Proto headers. Without it, req.ip is always 127.0.0.1 and req.secure is always false. See the Express behind proxies guide.

Step 3: Install PM2 and Start the App

sudo npm install -g pm2
cd /var/www/myapp
pm2 start app.js --name myapp
pm2 list
curl http://127.0.0.1:3000/health

Use an ecosystem.config.js File

Command-line flags are easy to forget on the next deploy. Put the config in the repo instead:

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'myapp',
      script: './app.js',
      cwd: '/var/www/myapp',
      instances: 'max',          // one process per CPU core
      exec_mode: 'cluster',
      max_memory_restart: '400M',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};
pm2 delete myapp
pm2 start ecosystem.config.js
OptionWhat it does
nameThe name you use in pm2 reload myapp, pm2 logs myapp
scriptEntry file (or npm with args: 'start')
instancesNumber of processes; 'max' means one per CPU core
exec_modefork (single process) or cluster (load-balanced across instances)
max_memory_restartRestart a process that grows past this memory, which contains leaks
envEnvironment variables; keep real secrets out of git
watchRestart on file changes. Useful in development, leave it off in production

Cluster mode caveat: each instance is a separate process with its own memory. In-memory sessions, caches, or WebSocket state won't be shared, so move them to Redis or a database before you scale past one instance. The full option list is in the PM2 ecosystem file docs.

Step 4: Start PM2 on Boot

pm2 startup systemd

PM2 prints a command that starts with sudo env PATH=.... Copy and run that exact command. It creates a systemd unit (pm2-<user>.service) for your user. Then save the current process list so it's restored at boot:

pm2 save

Run pm2 save again whenever you add or remove apps. Test it properly with sudo reboot, then pm2 list once you're back in.

Step 5: Put Nginx in Front as a Reverse Proxy

Create /etc/nginx/sites-available/myapp:

upstream myapp {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.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 Connection "";
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
curl -I http://example.com

keepalive 32 together with proxy_http_version 1.1 and an empty Connection header reuses connections to Node instead of opening a new one for every request. If your app uses WebSockets (Socket.IO, for example), add a location that passes the Upgrade and Connection: upgrade headers and raises proxy_read_timeout. The Nginx WebSocket proxying docs show the exact lines.

Header details, timeouts, and buffering are covered in how to set up Nginx as a reverse proxy.

Serve Static Files with Nginx

Nginx is much better at serving files than express.static. If your app has a public/ directory:

location /static/ {
    alias /var/www/myapp/public/;
    expires 7d;
    access_log off;
}

Step 6: Add HTTPS

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot adds the listen 443 ssl lines and an HTTP → HTTPS redirect. Because Nginx sends X-Forwarded-Proto https and Express trusts it, req.secure and secure cookies work without any TLS code in Node. Full guide: Let's Encrypt on Nginx with Certbot.

Step 7: Deploy Updates with Zero Downtime

cd /var/www/myapp
git pull
npm ci --omit=dev
pm2 reload ecosystem.config.js --update-env
CommandWhat happensDowntime
pm2 restart myappKills all processes, then starts themBrief, requests fail during the restart
pm2 reload myappIn cluster mode, restarts instances one at a timeNone if you run 2+ instances
pm2 reload ... --update-envSame, and picks up changed environment variablesNone
pm2 stop myapp / pm2 delete myappStops it / removes it from the listFull

For truly graceful reloads, have the app stop accepting connections and finish in-flight requests when it receives SIGINT, then exit:

const server = app.listen(port, '127.0.0.1');
process.on('SIGINT', () => {
  server.close(() => process.exit(0));
});

Step 8: Manage Logs

pm2 logs myapp           # tail stdout and stderr
pm2 logs myapp --lines 200
pm2 monit                # live CPU/memory per process
pm2 install pm2-logrotate

Without rotation, ~/.pm2/logs/ grows until the disk fills, and a full disk takes down far more than your Node app. pm2-logrotate rotates files by size and keeps a limited number of old ones.

PM2 vs systemd vs Docker

OptionGood forTrade-off
PM2Single servers, quick setup, cluster mode, easy reloadsOne more global tool to keep updated
systemd unitMinimal setups, one process, no extra dependenciesNo built-in clustering or zero-downtime reload
Docker / ComposeReproducible builds, multiple servicesMore moving parts; Nginx proxies to the container port

All three work behind the same Nginx config. Only the upstream address changes.

Troubleshooting Node.js Behind Nginx

SymptomCauseFix
502 Bad Gateway, connect() failed (111: Connection refused)App isn't running or listens on another portpm2 list, pm2 logs, check PORT matches upstream
App status errored in pm2 listCrash on startup (missing env var, missing module)pm2 logs myapp --err --lines 100
App gone after rebootpm2 startup command not run, or pm2 save skippedRun the printed sudo env PATH=... command, then pm2 save
pm2: command not found after a Node upgradeGlobal packages belong to the old Nodesudo npm install -g pm2, then pm2 update
req.ip is always 127.0.0.1Express doesn't trust the proxyapp.set('trust proxy', 'loopback')
504 Gateway Timeout on long requestsRequest exceeds proxy_read_timeout (60s default)Speed up the handler or raise the timeout for that location
WebSocket connections failUpgrade headers not forwardedAdd the WebSocket headers from the Nginx docs
Restart loop every few minutesmax_memory_restart too low or a real memory leakpm2 monit, raise the limit, fix the leak
Sessions randomly lostIn-memory sessions with cluster modeUse a shared store like Redis

More causes of gateway errors are in how to fix 502 Bad Gateway in Nginx.

Monitor the App from Outside

PM2 restarts crashed processes, but it can't tell you when the app is running and broken: a dependency is down, every request returns 500, or a crash loop is restarting it faster than it can serve traffic. From inside the server, all of that looks "online."

GreenCrew checks your site over HTTP from outside your network and sends alerts to Discord, Slack, or Telegram when it fails, plus a public status page for your users. Point the check at your /health route through Nginx (https://example.com/health), so a failure in Node, Nginx, TLS, or DNS all trigger it. The setup is in how to get website downtime alerts on Discord, Slack, and Telegram.

Frequently Asked Questions

Do I need Nginx if I use PM2?

They do different jobs. PM2 keeps Node running, and Nginx handles the internet-facing work: ports 80/443, TLS, static files, compression, rate limiting, and buffering slow clients. You can skip Nginx for internal tools, but for a public site the pair is the standard setup.

How many PM2 instances should I run?

Start with instances: 'max' (one per core) if the app is stateless and CPU-bound. On a small VPS with 1–2 GB of RAM, memory often runs out before CPU does, so set a fixed number like 2 and watch pm2 monit. Running at least two instances is what makes pm2 reload zero-downtime.

Should I run PM2 as root?

No. Run it as a normal deploy user that owns the app directory. Nginx binds the privileged ports, so Node never needs root, and a compromised app gets far less access to the system.

Where do I put environment variables and secrets?

Use the env block in ecosystem.config.js for non-secret settings. For secrets, load a .env file that isn't committed (for example with Node's --env-file flag on versions that support it, or the dotenv package), restrict its permissions with chmod 600, and run pm2 reload --update-env after changing it.

Conclusion

A production Node.js deployment on Ubuntu has three layers: an LTS Node.js runtime, PM2 to keep the app alive across crashes and reboots, and Nginx in front for TLS and traffic. Bind Node to localhost, register PM2 with pm2 startup and pm2 save, deploy with pm2 reload, and rotate your logs. Then add an external check with GreenCrew so you find out the app is down before your users do.