Back to the curriculum
Part 3 · Lesson 06b
Shipping Real Apps

Next.js on PM2 — The Docker-Free Path

Deploy Next.js to a VPS with Node, PM2, and nginx — no containers required.

intermediate16 min readUpdated 2026-04-11

Why PM2 instead of Docker

Docker is the modern default, but it is not the only path. PM2 has been the Node-native production process manager for a decade: it runs your next start server, restarts it on crash, spawns one worker per CPU core, and does zero-downtime reloads on deploy. For a single-box Next.js deployment where the only thing on the server is a Node app, PM2 is lighter than Docker, starts faster, and uses ~100 MB less RAM. Pick Docker when you have multiple services and want isolation. Pick PM2 when it is just Node, nginx, and maybe a database.

Prepare Next.js for production

Two things to set in your app before you deploy: output: "standalone" in next.config.js (bundles only the files you need — smaller deploy, faster cold start), and a start script that binds to the right port:

// next.config.js
module.exports = {
  output: 'standalone',
  experimental: { serverActions: { bodySizeLimit: '2mb' } },
}
// package.json
{
  "scripts": {
    "build": "next build",
    "start": "next start -p 3000"
  }
}

Test locally: npm run build && npm run start. If it runs on port 3000, it will run on your VPS.

Install Node and PM2 on the server

On a fresh Ubuntu 24.04 VPS (already hardened from Lesson 2), install Node.js from NodeSource and PM2 globally:

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pm2

NodeSource pins a specific major — use setup_22.x for Node 22 LTS. Avoid the Ubuntu-packaged nodejs (it is usually years behind). Verify: node -v && pm2 -v.

Clone, build, and start with PM2

Deploy as your non-root deploy user. Clone into /srv/app, install dependencies, build, and launch with PM2:

cd /srv
sudo git clone https://github.com/you/your-app.git app
sudo chown -R deploy:deploy app
cd app
npm ci
npm run build
pm2 start npm --name "web" -- start

pm2 start npm --name "web" -- start runs your start script under the name web. Check it with pm2 status. Logs are at pm2 logs web — stream them for a minute to confirm startup is clean.

ecosystem.config.js: the real way

Ad-hoc pm2 start commands get lost. Commit an ecosystem.config.js to your repo so the deploy is reproducible:

module.exports = {
  apps: [
    {
      name: 'web',
      script: 'npm',
      args: 'start',
      instances: 'max',
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      max_memory_restart: '512M',
      error_file: '/var/log/pm2/web-error.log',
      out_file: '/var/log/pm2/web-out.log',
    },
  ],
}

instances: "max" spawns one worker per CPU core. exec_mode: "cluster" shares port 3000 across them via Node's built-in cluster module — you get parallelism without running multiple ports. max_memory_restart: "512M" respawns a worker if it leaks. Start it with pm2 start ecosystem.config.js.

Survive reboots: pm2 startup

By default, PM2 forgets your processes on reboot. Fix it once:

pm2 startup systemd -u deploy --hp /home/deploy
# Copy-paste the sudo command PM2 prints
pm2 save

This generates a systemd unit that starts PM2 on boot and immediately restores the saved process list. After any pm2 start / pm2 restart, run pm2 save again so the state persists. Miss this step and your app vanishes the next time the host reboots for kernel updates.

nginx in front for SSL and static assets

PM2 runs Next.js on localhost:3000. nginx terminates HTTPS on ports 80/443 and forwards to it. Install and configure in one sitting:

sudo apt install -y nginx certbot python3-certbot-nginx
# /etc/nginx/sites-available/yourdomain.com
server {
  listen 80;
  server_name yourdomain.com;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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_cache_bypass $http_upgrade;
  }
}
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com

certbot rewrites your nginx config to add the SSL block and sets up an auto-renewal cron. Done — HTTPS in thirty seconds.

Zero-downtime reload on deploy

The whole reason to use cluster mode is so you can reload without dropping requests. Your deploy script is four lines:

cd /srv/app
git pull origin main
npm ci --production=false
npm run build
pm2 reload ecosystem.config.js --update-env

pm2 reload restarts workers one at a time, waiting for each to be ready before killing the next. Requests in flight finish on the old worker; new requests go to the new one. To automate it, wire the four lines into a GitHub Action that SSH-es in on every push to main — see Lesson 9 for the workflow template. If you need to clear Next.js' build cache between deploys, rm -rf .next before npm run build.

Watch the three things that matter

pm2 monit gives you a live TUI with CPU, memory, and logs per worker. pm2 logs --lines 200 tails the most recent output across all apps. pm2 describe web prints the full config of the named app. For structured monitoring, PM2 Plus (paid) ships built-in dashboards, but a free netdata container (Lesson 10) covers 90% of what you actually watch. Set a memory alert at 80% of your VPS RAM — cluster mode with a memory leak eats a whole box fast.

Key takeaways

  • PM2 is the Node-native alternative to Docker for single-service Next.js deploys
  • Use `output: "standalone"` and an `ecosystem.config.js` for reproducible deploys
  • `pm2 startup` + `pm2 save` is the step that makes your app survive reboots
  • nginx + certbot give you HTTPS in one command — no Traefik required
  • `pm2 reload` does zero-downtime rolling restart across cluster workers

Related documentation