Deploying Next.js on Your Own VPS with PM2 and Nginx Reverse Proxy
Run next start under PM2 on a loopback port, keep it alive across reboots, and put Nginx in front with the proxy headers Next.js expects to see.
Netbay Developer Relations
Netbay Engineering
On this page
Next.js is not a static site generator. With the App Router, server components, route handlers, and per-request data fetching, production Next.js is a Node process that renders HTML on demand. That process has to stay alive after your SSH session ends, restart if it crashes, and come back after a reboot. PM2 covers all three with one config file, and a systemd hook makes the whole process list boot-persistent.
The target shape for this post: Next.js listens on 127.0.0.1:3000, PM2 supervises it, and Nginx terminates TLS on 443 and proxies to it. Nothing but the reverse proxy is reachable from the internet.
Why not just export a static folder?
next build with output set to export produces a static bundle, and for a purely client-side app that is a fine path — serve it with Nginx like any SPA. But the moment a page reads cookies server-side, uses a route handler, or fetches secrets at request time, you need the server. Decide once per app: static export plus a file server, or next start under a process supervisor. This post is the second path, which covers everything Next.js can do.
Build, then smoke test
cd /srv/webapp
git pull --ff-only origin main
npm ci
npm run build
npm run start -- -H 127.0.0.1 -p 3000 # manual smoke test, Ctrl+C afternpm ci instead of npm install: ci wipes node_modules and refuses to run when package.json and the lockfile disagree, which is what you want on a server. If the smoke test renders your home page on the loopback URL, the build is good. Kill it and hand the process to PM2 — an app running inside your SSH session is a demo, not a deployment.
Give PM2 an ecosystem file
// /srv/webapp/ecosystem.config.js
module.exports = {
apps: [{
name: 'web',
cwd: '/srv/webapp',
script: 'node_modules/next/dist/bin/next',
args: 'start -H 127.0.0.1 -p 3000',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '600M',
env: {
NODE_ENV: 'production'
}
}]
};Calling the next binary directly instead of going through npm start removes a wrapper process, so the signals PM2 sends reach Next.js itself. Start it, then make the process list survive reboots:
pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd # then run the single command pm2 printspm2 save freezes the current process list and pm2 startup installs a systemd unit that restores it on boot. Skip the second command and your app is down until someone SSHes in after the next reboot. Bind to 127.0.0.1 and stay there: Next.js does not need a public port, and the loopback bind means an Nginx misconfiguration cannot expose the raw server. max_memory_restart is a guardrail against leaks, not a scaling strategy.
Nginx as the reverse proxy
server {
listen 443 ssl;
server_name app.example.com;
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_read_timeout 60s;
}
}Those proxy headers do real work. Next.js reads X-Forwarded-Proto to decide whether a request arrived over HTTPS; get it wrong and you invite redirect loops or mixed-content warnings. X-Forwarded-For is what rate limiting and access logs should use for the client IP instead of 127.0.0.1. If you add WebSocket routes later, put the Upgrade and Connection headers in a dedicated location block so they only apply where they are needed.
Deploys and rollbacks
A deploy is four commands: git pull, npm ci, npm run build, then pm2 reload web. reload gracefully restarts the process and waits until the new one accepts connections, so in-flight requests finish instead of dying mid-render. Rolling back means checking out the previous tag and rebuilding. Next.js compiles from source, so if builds take minutes on a small VPS, build in CI and ship a tarball to the server instead, then reload — the runtime step stays identical either way.
The takeaway: PM2 owns the process, systemd owns the boot, Nginx owns the public socket. Next.js just renders.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and test this exact setup — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance