Run Multiple Node.js Apps on One VPS With PM2 and Nginx

Published on

Run several Node.js apps on one VPS: a port, .env file and memory limit per app, one PM2 ecosystem file, and an nginx server block and certificate per domain.

A man with a tablet in a dim data center aisle between rows of server racks

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:

AppPortDomain
shop3001shop.example.com
api3002api.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 called pm2-<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 one pm2 status cover everything, which suits small apps of your own. But every app can read the others' files, and PM2 passes the environment of the shell running pm2 start to 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 },
    },
  ],
};
  • cwd is the folder PM2 starts the app in; script is resolved from there.
  • env sets the port, on top of the environment of the shell running pm2 start, which PM2 passes in first.
  • node_args passes flags to Node.js: --env-file loads 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 this PORT wins 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: true dates 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_name spelling, the link in sites-enabled and 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. Set server_names_hash_bucket_size in the http block of /etc/nginx/nginx.conf to the next power of two, here 128.
  • EADDRINUSE in an app's log: sudo ss -ltnp | grep 3002 shows what holds the port.
  • An app is missing after a reboot: its user never ran pm2 save, or its pm2 startup command was never run. Check systemctl status pm2-shop and sudo 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: