
You run a shop, an API and maybe a dashboard in Node.js, and a server for each feels wasteful. One VPS can run them all, but thrown together they collide: two apps want port 3000, one memory leak starves the rest, and a new domain shows the wrong site. This guide gives every app its own user or folder, port, environment file, memory limit, logs, nginx server block and certificate, on Ubuntu 24.04 or Debian 12.
Quick answer: Give each app its own folder or Linux user, port on 127.0.0.1 and .env file, and list the apps in one PM2 ecosystem file with a max_memory_restart value each. In front, nginx gets one server block per domain plus a catch-all default server that drops unknown names. Run certbot --nginx -d once per site for a certificate each. Use one user per app when the apps are unrelated or hold customer data: each gets its own PM2, logs and pm2-<user> service, which can take a hard MemoryMax= limit. Move an app to its own server when its busy hours squeeze the others.
Before You Start
You need Node.js 20.6 or later for the --env-file flag used below, plus PM2 and nginx; our Next.js deployment guide installs all three. Point an A record (and AAAA for IPv6) for every domain at the VPS, and use an account with sudo rights unless a step says otherwise. A Space-Node VPS is KVM with full root access, and its order form recommends Ubuntu 24.04 LTS, so everything below works as written. For Discord bots, which need no domains, see several Discord bots on one VPS.
The examples use two apps:
| App | Port | Domain |
|---|---|---|
| shop | 3001 | shop.example.com |
| api | 3002 | api.example.com |
Step 1: One User per App, or One User for All
PM2 keeps its process list, logs and modules in ~/.pm2 of the user who runs it, so the Linux user decides how separate the apps are.
- One user per app. Each app gets its own home folder, PM2 and, after
pm2 startup, systemd service calledpm2-<user>, and no app can read another's code or secrets. Choose this when the apps are unrelated, belong to different people or handle customer data. - One user with a folder per app, such as
/home/deploy/apps/shop. One PM2, one ecosystem file and onepm2 statuscover everything, which suits small apps of your own. But every app can read the others' files, and PM2 passes the environment of the shell runningpm2 startto the apps, so never export secrets there.
For one user per app:
sudo useradd --create-home --shell /bin/bash shop
sudo useradd --create-home --shell /bin/bash api
Ubuntu 24.04 creates home folders with mode 0750, so other users can't look inside. Debian 12 leaves HOME_MODE unset in /etc/login.defs and falls back to umask 022, giving 0755, so close them there:
sudo chmod 750 /home/shop /home/api
Switch to an app user with sudo -iu shop and put its code in ~/app. For the shared layout, create one user, deploy, the same way.
Step 2: Give Every App Its Own Port on 127.0.0.1
Two apps can't listen on one port: the second stops with listen EADDRINUSE: address already in use 127.0.0.1:3002. Give each app a fixed port and bind it to 127.0.0.1. When a Node.js server calls listen() without a host, it accepts connections on every address, so http://your-ip:3001 would bypass nginx and HTTPS.
const port = Number(process.env.PORT) || 3001;
app.listen(port, '127.0.0.1'); // Express
// server.listen(port, '127.0.0.1'); // node:http
Check the result:
sudo ss -ltnp
This lists listening TCP sockets with numeric ports and their processes. You want 127.0.0.1:3001 and 127.0.0.1:3002; *:3001 or 0.0.0.0:3001 means every address.
Step 3: Describe the Apps in One PM2 Ecosystem File
As deploy, create ~/ecosystem.config.cjs:
// Ports: shop 3001, api 3002
module.exports = {
apps: [
{
name: 'shop',
cwd: '/home/deploy/apps/shop',
script: 'server.js',
node_args: ['--env-file=/home/deploy/apps/shop/.env', '--max-old-space-size=384'],
max_memory_restart: '450M',
time: true,
env: { NODE_ENV: 'production', PORT: 3001 },
},
{
name: 'api',
cwd: '/home/deploy/apps/api',
script: 'dist/index.js',
node_args: ['--env-file=/home/deploy/apps/api/.env', '--max-old-space-size=256'],
max_memory_restart: '320M',
time: true,
env: { NODE_ENV: 'production', PORT: 3002 },
},
],
};
cwdis the folder PM2 starts the app in;scriptis resolved from there.envsets the port, on top of the environment of the shell runningpm2 start, which PM2 passes in first.node_argspasses flags to Node.js:--env-fileloads the app's secrets from an absolute path. Node.js won't start without the file, and a variable already in the environment beats the file, so thisPORTwins over one in.env.- Without
instances, each app runs as one process in fork mode, PM2's default.instances: 'max'runs as many processes as PM2 detects CPUs, so three such apps on a 4 vCore VPS make twelve processes. time: truedates every log line.
Each .env file holds NAME=value lines, with # for comments. Lock the files down, start the apps and save the list:
chmod 600 ~/apps/shop/.env ~/apps/api/.env
cd ~
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
pm2 startup prints a command starting with sudo env PATH=$PATH:; run it from your admin account to write and enable a systemd service, here pm2-deploy, that restores the saved list at boot. With one user per app, each user keeps this file with only its own entry and paths in its home, such as /home/shop/app, and runs these commands, giving you pm2-shop and pm2-api.
After editing an app's .env, pm2 restart api is enough, since Node.js reads the file at start. After changing its entry in the ecosystem file, run pm2 delete api, pm2 start ecosystem.config.cjs --only api and pm2 save.
Step 4: Set a Memory Limit for Each App
max_memory_restart restarts an app once its memory passes the value, written with K, M or G. PM2 checks every 30 seconds, so a fast leak can fill the RAM in between, and each restart leaves restarted because it exceeds --max-memory-restart value in ~/.pm2/pm2.log.
--max-old-space-size sets the maximum size of V8's old memory section, in MiB. Near it, V8 spends more time collecting garbage; when it can't free enough, the app exits with a FATAL ERROR: line ending in JavaScript heap out of memory, and PM2 starts it again. Set it a little below max_memory_restart, because a process also uses memory outside the heap, and set both above the app's normal peak.
A Hard Ceiling per User With systemd
PM2's limit is a restart rule, not one the kernel enforces. With one user per app, each app's PM2 runs in its own pm2-<user> service, so a systemd limit on that service is a per-app ceiling. Run sudo systemctl edit pm2-shop and add:
[Service]
MemoryHigh=600M
MemoryMax=700M
OOMPolicy=continue
Above MemoryHigh, systemd's main control, the service is slowed and its memory reclaimed aggressively. It counts PM2's daemon and modules too, so set it well above the app's max_memory_restart. MemoryMax, the last line of defense, is absolute: if usage can't stay under it, the out-of-memory killer acts inside that unit only. After such a kill, systemd stops the whole unit by default; OOMPolicy=continue keeps PM2 running, so it restarts just the app.
These limits cover only processes the service started, and pm2 startup enabled the service without starting it. So once, with a short stop of the app: as shop, run pm2 save and pm2 kill, then sudo systemctl start pm2-shop from your admin account.
Step 5: One nginx Server Block per Domain
Every site needs the same proxy headers, so put them in /etc/nginx/snippets/node-proxy.conf:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Both packages ship /etc/nginx/proxy_params, but Ubuntu 24.04's passes $http_host and Debian 12's now $host, so your own file is the same on both. Our Next.js guide explains each header. Then create one file per site, /etc/nginx/sites-available/shop:
server {
listen 80;
listen [::]:80;
server_name shop.example.com;
access_log /var/log/nginx/shop.access.log;
error_log /var/log/nginx/shop.error.log;
location / {
proxy_pass http://127.0.0.1:3001;
include snippets/node-proxy.conf;
}
}
Copy it to /etc/nginx/sites-available/api with its own name, port and log files. Keep the include inside each location: nginx inherits proxy_set_header lines only into a level that sets none, so one extra header in a location would drop server-level ones.
nginx picks the server block by the Host header and sends unmatched names to the port's default server: the first block, unless one is marked default_server. On 443 that would be whichever site nginx read first, so a stranger's domain pointed at your IP address would show one of your sites with a certificate warning. Replace the package's default site with a catch-all, /etc/nginx/sites-available/catch-all:
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
return 444;
}
444 closes the connection without a response, and ssl_reject_handshake refuses the TLS handshake, so the block needs no certificate. It exists since nginx 1.19.4; Ubuntu 24.04 ships 1.24 and Debian 12 ships 1.22. Only one block per port can be the default, so drop the package's site as you enable yours:
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/catch-all /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Step 6: A Certificate for Every Domain
Let's Encrypt checks each name over port 80, so set up DNS first and, if UFW is active, run sudo ufw allow 'Nginx Full' for ports 80 and 443.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d shop.example.com
sudo certbot --nginx -d api.example.com
sudo certbot certificates
One Certbot run per site, as its guide shows, gives each site its own certificate, named after its first -d domain; certbot certificates lists them with expiry dates. The package's systemd timer runs Certbot twice a day for renewals, and sudo certbot renew --dry-run tests them. Our nginx and SSL guide has more on this setup.
Test a new domain with sudo certbot certonly --nginx --dry-run -d new.example.com: it uses Let's Encrypt's staging server and saves nothing, while real requests count toward limits such as 5 certificates for the same set of names every 7 days. To remove an app, delete its nginx site files and reload nginx before sudo certbot delete --cert-name api.example.com; Certbot warns that deleting a certificate the server still uses can leave it not working.
Step 7: Keep the Logs Apart
pm2 logs streams all of that PM2's apps; pm2 logs api --lines 100 shows one. The files sit in ~/.pm2/logs, so with one user per app, each app's logs stay in its own home. For rotation, PM2's docs point to pm2-logrotate; install it as every user that runs PM2, since modules also live in ~/.pm2:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:compress true
By default it rotates a file at 10 MB, keeps 30 rotated files and also rotates every night; our PM2 production guide covers more settings. The access_log and error_log lines from step 5 give each site its own nginx logs, and the package's logrotate rule already rotates every *.log file in /var/log/nginx daily, keeping 14.
When to Move an App to Its Own Server
Split an app off when:
- Its busy hours squeeze the others. It keeps hitting its memory limit after you've fixed leaks, or its CPU use slows the other sites. Our upgrade guide shows how to read memory pressure, OOM kills and CPU steal.
- Someone else needs root. Separate users keep apps apart, but anyone with sudo can read every app's files and secrets.
- It needs its own maintenance window. A kernel update and reboot take all apps down at once.
If all apps grow together, a bigger plan is simpler than a second server: on Space-Node you upgrade from the client area, and the VPS plans list each size.
If It Still Fails
- A domain shows another app, or the connection closes at once: the request reached the default server. Check the
server_namespelling, the link insites-enabledand the domain's DNS;sudo nginx -T | grep -E "server_name|listen"shows every block nginx loaded. a duplicate default server for 0.0.0.0:80: the package's default site and your catch-all both claim the port. Remove/etc/nginx/sites-enabled/default.conflicting server name "api.example.com" on 0.0.0.0:80, ignored: two blocks share a name, often after copying a file. nginx keeps the first.could not build server_names_hash, you should increase server_names_hash_bucket_size: 64: a long domain name. Setserver_names_hash_bucket_sizein thehttpblock of/etc/nginx/nginx.confto the next power of two, here 128.EADDRINUSEin an app's log:sudo ss -ltnp | grep 3002shows what holds the port.- An app is missing after a reboot: its user never ran
pm2 save, or itspm2 startupcommand was never run. Checksystemctl status pm2-shopandsudo journalctl -u pm2-shop. - 502 Bad Gateway on one domain: our nginx 502 guide goes through every cause.
FAQ
Can two apps share a port?
No, the second gets EADDRINUSE; nginx is what lets all domains share ports 80 and 443, passing each request to its app's port. PM2's cluster mode shares a port only among one app's processes.
Can the apps live on paths of one domain instead of subdomains?
Yes: add location /api/ { proxy_pass http://127.0.0.1:3002/; include snippets/node-proxy.conf; } to the shop's server block. Because proxy_pass ends in a URI (the final /), nginx replaces the matching /api/ with it, so the app receives /users for /api/users. The app must still put /api/ in its own links and redirects; subdomains avoid that.
Can each app run a different Node.js version?
Yes. Set interpreter in that app's entry to the full path of another node binary, such as one nvm installed under ~/.nvm/versions/node/. Keep the app in fork mode, PM2's default: in cluster mode PM2 forks workers from its own Node.js.
One certificate for all apps, or one per app?
A Let's Encrypt certificate can hold up to 100 names, depending on its profile, but then every change to the list means a new certificate for all sites; one per site keeps them independent. A wildcard such as *.example.com needs the DNS-01 challenge, and the nginx plugin validates over HTTP-01 on port 80.
How many apps fit on one VPS?
Count memory first: run pm2 list --sort memory:desc during busy hours, add nginx, any database and headroom, and compare with the plan's RAM. Our RAM guide walks through the sums.
Sources
All opened on 31 October 2026:
- PM2 docs: Ecosystem File, Environment Variables, Process Management, Memory Limit, Startup Script, Logs, Cluster Mode, Module System and the pm2-logrotate README
- PM2 7.1.1 source: paths.js, constants.js, Common.js, Client.js, Worker.js, ForkMode.js, ClusterMode.js, Startup.js, systemd.tpl, schema.json
- Node.js: CLI options, net, errors.js, uv.h, node_errors.cc; Express: app.listen(); nvm README
- nginx docs: How nginx processes a request, Server names, ngx_http_core_module, ngx_http_proxy_module, ngx_http_ssl_module, ngx_http_rewrite_module, ngx_http_log_module, core module, command-line parameters
- nginx source: ngx_http.c, ngx_hash.c, ngx_http_ssl_module.c in 1.24.0 and 1.22.1
- nginx packages: Ubuntu 24.04 debian folder and Debian 12 debian folder, for nginx.conf, the default site, proxy_params, the logrotate rule, the UFW profile and the versions
- Certbot: User Guide, python3-certbot-nginx in Debian 12 and Ubuntu 24.04, certbot.timer in Ubuntu and Debian
- Let's Encrypt: Challenge Types, Rate Limits
- systemd: systemd.resource-control, systemd.service, systemd-system.conf, systemctl, system.conf.in for v252 and v255
- Users and tools: login.defs for Ubuntu 24.04 and Debian 12, useradd(8), sudo(8), chmod(1), ln(1), rm(1), ss(8), ss.c, ufw(8)