How to Host Multiple Next.js Apps on One VPS with PM2 and Nginx (Without Port Conflicts)

I run four separate Next.js sites off a single DigitalOcean droplet — a coupon and course-discovery site, a news site, a hotel website, and a couple of internal tools. They all share one server, one Nginx install, and one PM2 process manager. Most of the time this works well: it's cheap, it's simple to reason about, and I don't need four separate servers for four low-to-medium traffic sites.
The one recurring failure mode is a port conflict — two apps configured to listen on the same port, so one of them crash-loops in PM2 while the other stays up. I've hit this more than once, most recently when a new app's PM2 config still had the port setting copied from an older project. This guide is the exact setup I use to avoid that, plus how to actually diagnose it when it happens anyway.
The Quick Answer
- Give every app its own dedicated port (3000, 3001, 3002…) — never let two apps default to the same one
- Run each app under PM2 with an
ecosystem.config.jsthat pins the port explicitly, not just inpackage.json - Point each domain at its app through an Nginx server block that reverse-proxies to
localhost: - When something won't start, check
pm2 logsfirst — port conflicts show up immediately asEADDRINUSE
Why This Approach (Instead of Docker or Separate VPS's)
Before the how-to: why bother with PM2 + Nginx instead of just containerizing everything or renting a separate droplet per site? For a handful of low-to-moderate traffic sites, a single VPS running multiple PM2 processes behind Nginx is dramatically cheaper and simpler to operate than the alternatives. Docker adds real value once you have a team, CI/CD, or need to guarantee environment parity across machines — for one person managing a handful of Next.js sites, it's often more overhead than it's worth. A separate VPS per site multiplies your hosting bill for sites that individually use a fraction of a server's capacity. The trade-off is that everything shares the same machine's resources and the same process manager's configuration — which is exactly why port management has to be deliberate rather than assumed.Step 1: Give Every App Its Own Port
next start with no flags defaults to port 3000. If you have more than one Next.js app on the same server and you don't explicitly assign ports, the second one you start will either fail outright or — worse — silently conflict with the first depending on how it's started.
I keep a simple mental map of which app owns which port:
- App A → 3000
- App B → 3006
- App C → 3010
- App D → 3020
package.json start script so it's explicit rather than relying on a default:
json
{
"scripts": {
"start": "next start -p 3006"
}
}Step 2: Configure PM2 with an Explicit Port in the Environment
This is the step that actually caused a real outage for me. I have a sharedecosystem.config.js pattern for each app, and it's tempting to copy one app's config as a starting point for the next one. If you copy the file and forget to change the PORT value in the env block, PM2 will happily start the new app — and it will immediately crash, because the port is already taken by the app you copied from.
Here's the pattern I use, one file per app:
js
The // ecosystem.config.js
module.exports = {
apps: [{
name: 'app-b',
script: 'npm',
args: 'start',
cwd: '/var/www/app-b',
instances: 1,
exec_mode: 'fork',
max_memory_restart: '1200M',
node_args: '--max-old-space-size=1024',
env: {
NODE_ENV: 'production',
PORT: 3006, // <-- this is the line to double-check every time
HOSTNAME: '0.0.0.0',
},
}],
};PORT value here is what actually wins at runtime for most Next.js start scripts — if it doesn't match what you put in package.json, the environment variable takes precedence. That mismatch is a second, sneakier way to end up with a conflict: the package.json script says -p 3006, but the ecosystem file's env.PORT still says 3000 from a copy-paste, and the app binds to 3000 anyway.
The fix, if you ever hit this: don't just pm2 restart — a restart reuses the already-loaded environment and won't pick up a changed PORT value. You need to fully remove and re-add the process:
bash
pm2 delete app-b
pm2 start ecosystem.config.js
pm2 saveStep 3: Diagnosing a Port Conflict When It Happens
The symptom is always the same:pm2 list shows the app status as errored, and it will show a high restart count because PM2 keeps trying and failing in a tight loop.
bash
The log will show something like:
pm2 list
# app shows status: errored, restarts climbing rapidly
pm2 logs app-b --lines 30text
That tells you exactly what's wrong — something else already has that port. Find out what:
Error: listen EADDRINUSE: address already in use :::3000bash
This gives you the PID and process name currently bound to that port. From there it's usually one of two situations: a genuinely different app is supposed to be on that port (fix the conflicting app's config instead), or it's an orphaned process from an earlier crash that never released the port (kill it and restart cleanly).
sudo lsof -i :3000
# or
sudo ss -tulpn | grep :3000Step 4: Route Each Domain to Its App with Nginx
Once every app has its own port and is confirmed running (pm2 list shows online, not errored), Nginx is what maps a public domain name to the right internal port. Each app gets its own server block:
nginx
The server {
listen 80;
server_name appb.example.com www.appb.example.com;
location / {
proxy_pass http://localhost:3006;
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;
}
}Upgrade/Connection headers matter specifically for Next.js — they're what let WebSocket connections (used by Next's dev fast-refresh and some app features) pass through the proxy correctly. Save this in /etc/nginx/sites-available/appb.example.com, symlink it into sites-enabled, then test and reload:
bash
sudo ln -s /etc/nginx/sites-available/appb.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxnginx -t is worth running every time before reloading — it validates the config syntax and will catch a typo before it takes down every site on the server, not just the one you're editing.
Step 5: Add HTTPS with Certbot
Once the plain-HTTP routing works, adding a free TLS certificate for each domain is one command per domain with Certbot:bash
Certbot edits the Nginx server block automatically to add the certificate paths and a redirect from port 80 to 443. It also sets up a renewal cron job, so this is generally a one-time step per domain.
sudo certbot --nginx -d appb.example.com -d www.appb.example.comKeeping Track as You Add More Apps
Once you're past two or three apps, an ad-hoc mental map of ports stops being reliable. I keep a plain text file on the server itself —/root/PORTS.md — listing every app, its port, its PM2 process name, and its domain. It takes thirty seconds to update when adding a new app and has saved me from at least one repeat of the exact conflict this guide describes.
A minimal version:
text
app-a → port 3000 → pm2 name: app-a → domain: appa.example.com
app-b → port 3006 → pm2 name: app-b → domain: appb.example.com
app-c → port 3010 → pm2 name: app-c → domain: appc.example.comFrequently Asked Questions
Can two Next.js apps share the same port if they're on different domains?
No — the port is what Nginx proxies to internally, and it's independent of the public domain. Two apps on the same port will conflict regardless of what domain name points to them, because the operating system only allows one process to bind to a given port at a time.Do I need Docker to do this safely?
No. Docker can make port isolation more automatic (each container can bind to the same internal port and be mapped to different host ports), but it's not required. Explicit, well-documented port assignment in PM2 configs achieves the same isolation without the added complexity of container orchestration for a small number of apps.What happens if I restart the server — do my apps come back automatically?
Only if you've runpm2 save after starting your apps, and set up PM2's startup script with pm2 startup (which configures a systemd service to resurrect your saved process list on boot). Without both steps, a server reboot will leave all your apps stopped.
How many Next.js apps can realistically run on one small VPS?
This depends entirely on traffic and the memory/CPU size of the droplet, not on some fixed app-count limit. On a modest droplet (a few GB of RAM), three to five low-to-moderate-traffic Next.js apps running in PM2's fork mode is a reasonable range before you should start watching memory pressure closely withpm2 monit or considering a bigger instance.
Is PM2's cluster mode worth using here?
Cluster mode runs multiple instances of one app across CPU cores for higher throughput on a single app — it solves a different problem than hosting multiple separate apps. For most personal or small-business multi-site setups, fork mode (one process per app) is simpler to reason about and sufficient.Tags:Next.jsPM2NginxVPSDevOpsNode.jsreverse proxy
Justin is a self-taught developer who builds and runs DeelCart himself — from the articles to the server it runs on. He manages his own Linux infrastructure and writes guides based on tools and workflows he actually uses day to day.