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

Source: https://vpsnine.com/tutorials/deploy-nodejs-app-pm2-nginx-ubuntu · Updated: 2026-10-21

Difficulty: intermediate · Time: about 30 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

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

```bash
curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install -y nodejs git
```

Check it worked:

```bash
node -v
npm -v
```

On 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](https://vpsnine.com/tutorials/install-nodejs-ubuntu) 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:

```bash
sudo npm install -g pm2
pm2 -v
```

This 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:

```bash
sudo adduser --disabled-password --gecos "" deploy
sudo -iu deploy
```

The 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:

```bash
git clone https://github.com/your-name/myapp.git ~/myapp
cd ~/myapp
npm ci --omit=dev
```

`npm 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:

```bash
mkdir -p ~/myapp && cd ~/myapp
npm init -y
npm install express
```

```js
// ~/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`:

```js
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: 1` runs the app in PM2's cluster mode with one process, which is what lets `pm2 reload` swap 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_restart` restarts the app if it grows past 300 MB, a safety net for slow memory leaks. Size it to your plan's memory.
- The `.cjs` extension makes it load whether or not your `package.json` says `"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

```bash
cd ~/myapp
pm2 start ecosystem.config.cjs
pm2 ls
```

Check it worked: `pm2 ls` shows `myapp` with the status `online`, and the app answers on its local port:

```bash
curl http://127.0.0.1:3000
```

```text
Hello from Node.js v24.22.1
```

If 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:

```bash
pm2 save
pm2 startup systemd -u deploy --hp /home/deploy
```

PM2 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:

```bash
systemctl status pm2-deploy
```

It 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:

```bash
sudo apt install -y nginx
```

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

```nginx
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 `map` and the `Upgrade`/`Connection` headers let WebSockets (Socket.IO, live reload, GraphQL subscriptions) through. For ordinary requests the `Connection` header 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, add `app.set("trust proxy", "loopback")` so `req.ip` and `req.secure` use them.
- `map` is allowed once per variable name across all sites. If you proxy a second app, leave the `map` block out of its file.

Enable the site, drop the default one and reload:

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

Check it worked: `nginx -t` says `syntax is ok` and `test is successful`, and the app answers through nginx:

```bash
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:

```bash
sudo ufw allow "Nginx Full"
```

On VPSNine, a server with a [panel firewall](https://vpsnine.com/help/server-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](https://vpsnine.com/help/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:

```bash
cd ~/myapp
git pull
npm ci --omit=dev
pm2 reload myapp
```

`pm2 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:

```bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
```

Read 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:

```text
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 `server` block, or use [Caddy as a reverse proxy for several apps](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-apps).
- Add a database: [Install PostgreSQL on Ubuntu 24.04](https://vpsnine.com/tutorials/install-postgresql-ubuntu) or [Redis](https://vpsnine.com/tutorials/install-redis-ubuntu) for sessions and caches.
- Watch the server's CPU, memory and disk: [Monitor your server](https://vpsnine.com/help/monitor-your-server).
- Prefer containers? [Run a multi-container app with Docker Compose](https://vpsnine.com/tutorials/docker-compose-reverse-proxy).
