--- title: "How to Deploy a Node.js App with PM2 on Ubuntu 24.04" description: "Deploy a Node.js app with PM2 on Ubuntu 24.04: run it in cluster mode, start it on boot with systemd, rotate logs, reload with zero downtime and add Nginx." category: "Application Deployment" language: en source: https://cubepath.com/docs/application-deployment/node-js-application-deployment-with-pm2 updated: 2026-09-25 --- # How to Deploy a Node.js App with PM2 on Ubuntu 24.04 PM2 is a process manager for Node.js that keeps applications running, restarts them when they crash, spreads them across CPU cores in cluster mode and collects their logs. In this tutorial you will deploy a small Express application on Ubuntu 24.04 with PM2, describe it in an ecosystem file, make PM2 start it automatically at boot through systemd, rotate its logs, and publish it through Nginx. You will also run a zero-downtime reload, which is how you roll out new versions. ## Prerequisites To follow this guide you need: - A server running Ubuntu 24.04 LTS, for example a CubePath VPS. - A non-root user with `sudo` privileges. This guide uses `your_user` in paths; replace it with your username. - Node.js and npm installed system-wide, for example Node.js 24 LTS from the NodeSource repository. Check with `node --version`. - Optionally, a domain name with an A record pointing to `your_server_ip` if you want to serve the app by name. ## Step 1 - Installing PM2 Install PM2 globally so the `pm2` command is available to every user: ```bash sudo npm install -g pm2 ``` Verify the installation: ```bash pm2 --version ``` ```output 6.0.13 ``` The first time you run a `pm2` command as your user, PM2 starts its daemon and creates `~/.pm2`, where it keeps logs, PID files and the saved process list. Run PM2 as your normal user, not with `sudo`, so the daemon and the apps it launches do not run as root. ## Step 2 - Creating a sample application If you already have an application, you can skip to Step 3 and use its entry point instead. Otherwise, create a minimal Express API to follow along: ```bash mkdir -p ~/apps/my-api cd ~/apps/my-api npm init -y npm install express ``` Create the entry point: ```bash nano app.js ``` ```javascript const express = require('express'); const app = express(); const port = process.env.PORT || 3000; app.get('/', (req, res) => { res.json({ status: 'ok', env: process.env.NODE_ENV, pid: process.pid }); }); const server = app.listen(port, '127.0.0.1', () => { console.log(`my-api listening on 127.0.0.1:${port}`); }); // Let PM2 stop or reload the process cleanly process.on('SIGINT', () => { server.close(() => process.exit(0)); }); ``` The `SIGINT` handler matters: when PM2 reloads or stops a process it sends `SIGINT` first, and closing the server lets in-flight requests finish before the process exits. ## Step 3 - Writing an ecosystem file You can start an app with `pm2 start app.js`, but an ecosystem file keeps every option in version control and makes restarts reproducible. Create it in the project root: ```bash nano ~/apps/my-api/ecosystem.config.js ``` ```javascript module.exports = { apps: [ { name: 'my-api', script: './app.js', cwd: '/home/your_user/apps/my-api', instances: 'max', exec_mode: 'cluster', max_memory_restart: '300M', time: true, env: { NODE_ENV: 'development', PORT: 3000, }, env_production: { NODE_ENV: 'production', PORT: 3000, }, }, ], }; ``` What these options do: - `instances: 'max'` with `exec_mode: 'cluster'` starts one worker per CPU core. All workers share port 3000; PM2 balances connections between them, so you do not need one port per instance. - `max_memory_restart` restarts a worker that grows beyond 300 MB, a safety net against memory leaks. - `time: true` prefixes every log line with a timestamp. - `env_production` is applied when you pass `--env production`. Start the app with the production environment: ```bash cd ~/apps/my-api pm2 start ecosystem.config.js --env production ``` List the processes: ```bash pm2 ls ``` PM2 prints a table with one row per worker. On a 2 vCPU server you will see two rows named `my-api`, both with mode `cluster` and status `online`. Send a few requests and notice that the `pid` changes as PM2 distributes them: ```bash curl http://127.0.0.1:3000 ``` ```output {"status":"ok","env":"production","pid":14231} ``` ## Step 4 - Starting PM2 at boot PM2 can generate a systemd unit that starts the daemon at boot and restores the processes you saved. Run: ```bash pm2 startup systemd ``` Because you are not root, PM2 does not change the system itself. It prints the exact command to run: ```output [PM2] To setup the Startup Script, copy/paste the following command: sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u your_user --hp /home/your_user ``` Copy and run the command that PM2 printed on your server. It creates and enables a unit named `pm2-your_user.service`. Now save the current process list. This is what PM2 restores at boot: ```bash pm2 save ``` Check the systemd unit: ```bash systemctl status pm2-your_user ``` ```output ● pm2-your_user.service - PM2 process manager Loaded: loaded (/etc/systemd/system/pm2-your_user.service; enabled; preset: enabled) Active: active (running) since ... ``` To confirm the full cycle, reboot with `sudo reboot`, reconnect and run `pm2 ls`. Both workers should be `online` again. Remember to run `pm2 save` again every time you add or remove apps. ## Step 5 - Managing logs PM2 writes each app's standard output and error streams to `~/.pm2/logs/`. Follow them live: ```bash pm2 logs my-api --lines 20 ``` Press `CTRL+C` to stop following. These files grow forever by default, so install the `pm2-logrotate` module: ```bash pm2 install pm2-logrotate ``` Configure it to rotate at 10 MB, keep 14 files and compress old ones: ```bash pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 14 pm2 set pm2-logrotate:compress true ``` Check the module settings: ```bash pm2 conf pm2-logrotate ``` The module runs as its own PM2 process, so `pm2 ls` now shows it under a separate `Module` section. Run `pm2 save` again so it is restored after a reboot. For a live view of CPU and memory per worker, use `pm2 monit`. ## Step 6 - Deploying updates with zero downtime In cluster mode, `pm2 reload` restarts workers one at a time and waits for each new worker to come online before stopping the next old one, so the app keeps accepting connections during a deploy. A typical update from a Git checkout looks like this: ```bash cd ~/apps/my-api git pull npm ci --omit=dev pm2 reload ecosystem.config.js --env production --update-env ``` `npm ci` installs exactly what `package-lock.json` specifies, `--omit=dev` skips development dependencies, and `--update-env` makes PM2 apply any environment variables you changed in the ecosystem file. To test the reload, run a request loop in a second SSH session: ```bash while true; do curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000; sleep 0.2; done ``` Run `pm2 reload my-api` in the first session. The loop should keep printing `200` without any connection errors. Stop it with `CTRL+C`. > Note: `pm2 restart` stops and starts all workers at once and causes a short outage. Use `reload` for deployments. ## Step 7 - Publishing the app through Nginx The app listens only on `127.0.0.1`, so it is not reachable from outside. Nginx will accept public traffic on port 80 and proxy it to PM2. Install Nginx: ```bash sudo apt install nginx ``` Create a server block: ```bash sudo nano /etc/nginx/sites-available/my-api ``` ```nginx server { listen 80; server_name your_domain; 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"; } } ``` Replace `your_domain` with your domain name, or with `your_server_ip` if you do not have one. The `Upgrade` and `Connection` headers let WebSocket connections pass through. Enable the site, test the configuration and reload Nginx: ```bash sudo ln -s /etc/nginx/sites-available/my-api /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` If UFW is active, allow HTTP and HTTPS: ```bash sudo ufw allow 'Nginx Full' ``` From your local machine, open `http://your_domain`. You should see the JSON response with `"env":"production"`. To add HTTPS, install Certbot with `sudo apt install certbot python3-certbot-nginx` and run `sudo certbot --nginx -d your_domain`. ## Troubleshooting **The app shows `errored` or keeps restarting in `pm2 ls`.** Read the error log, which usually contains the stack trace: ```bash pm2 logs my-api --err --lines 50 ``` A common cause is `EADDRINUSE`, meaning another process already uses port 3000. Find it with `sudo ss -ltnp 'sport = :3000'`. **Apps do not come back after a reboot.** Check that the unit is enabled with `systemctl is-enabled pm2-your_user` and that you ran `pm2 save` after the last change. If you upgraded Node.js or moved PM2, regenerate the unit with `pm2 unstartup systemd`, then repeat Step 4. **`pm2 ls` shows no apps, but the site still works.** You are probably looking at another user's PM2 daemon, for example after running `sudo pm2`. Each user has a separate daemon in their own `~/.pm2`. Always run PM2 as the user that owns the app. **Nginx returns `502 Bad Gateway`.** Nginx cannot reach the app. Confirm it answers locally with `curl http://127.0.0.1:3000` and check `sudo tail -n 20 /var/log/nginx/error.log`. ## Conclusion Your Node.js app now runs under PM2 in cluster mode, starts automatically at boot through a systemd unit, rotates its logs and accepts public traffic through Nginx. You also have a repeatable deploy procedure that reloads workers without downtime. As next steps you can: - Secure the site with a Let's Encrypt certificate and redirect HTTP to HTTPS. - Move secrets out of the ecosystem file into environment variables loaded from a file only your user can read. - Automate the Step 6 commands from your CI pipeline over SSH.