A common pattern for indie founders running several small products: one VPS, several Node.js apps, each living on its own domain or subdomain, each independently deployable without touching the others. Nginx as a reverse proxy plus PM2 for process management handles this cleanly without needing Docker or a full orchestration layer.
The goal: one server, several isolated apps
Each app listens on its own local port (never exposed directly to the internet), and Nginx routes incoming requests to the right app based on the domain in the request. From the outside, each product looks like it has its own dedicated server; internally, they share the same box's CPU, RAM, and disk.
Running each app under PM2
pm2 start app.js --name app-one -- --port 3001
pm2 start app.js --name app-two -- --port 3002
pm2 save
pm2 startup
pm2 startup generates and registers a systemd service so all your apps come back up automatically after a server reboot — don't skip this step, it's the difference between a reboot being a non-event and being a 2am wake-up call. Once each app restarts cleanly on its own, the next thing worth automating is the deploy itself — see our guide to wiring PM2 into a GitHub Actions pipeline instead of deploying by hand.
The Nginx server block pattern
Each app gets its own server block, listening on the same ports 80/443 but distinguished by server_name:
server {
listen 80;
server_name appone.com www.appone.com;
location / {
proxy_pass http://127.0.0.1:3001;
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_cache_bypass $http_upgrade;
}
}
Duplicate this block per domain, changing server_name and the proxied port. If you're using aaPanel, this same configuration can go into the "Reverse Proxy" tab per website instead of hand-editing config files, which is worth using once you have more than two or three apps to keep track of.
Adding SSL per domain
Issue a separate Let's Encrypt certificate per domain (or one multi-domain certificate if you prefer, though separate certs are simpler to reason about when you add or remove apps later). See our Let's Encrypt guide for the renewal setup — with multiple domains on one server, a broken auto-renewal is easy to miss until a certificate silently expires on one of them.
Keeping apps isolated from each other
Run each app as its own system user where practical, and never let two apps share a database or Redis instance unless they're genuinely part of the same product. It's tempting to reuse one Postgres instance with separate schemas for "efficiency," but a bug or migration mistake in one app can then affect another. The isolation cost (a bit more RAM for a second database process) is cheap insurance.
A note on WebSockets
If any app uses WebSockets (live chat, real-time dashboards), make sure the Upgrade and Connection headers are passed through as shown above, and check that your provider or any CDN in front of Nginx isn't buffering or timing out long-lived connections — this is the most common cause of "WebSockets work locally but drop in production" reports.
Frequently asked questions
How many Node.js apps can realistically run on one VPS this way?
It's bound by RAM more than anything else — each Node process has its own memory footprint, so the practical limit is however many apps' combined memory usage fits comfortably within your server's RAM, leaving headroom for the database and OS. On a 4-8GB server, a handful of small-to-medium apps is typical.
Do I need a separate Nginx instance per app?
No — one Nginx installation handles routing for all your apps through separate server blocks, distinguished by domain name. This is the whole point of the reverse proxy pattern: one entry point, many backend apps.
What happens if two apps need different Node.js versions?
Use a Node version manager (nvm) and start each app's PM2 process using its own specified Node binary path, or containerize just the apps with conflicting requirements (see our Docker vs bare metal guide) while leaving the rest running directly on the host.