Running several Node.js apps on one VPS with nginx, pm2 and Let’s Encrypt

My license server, a file-sharing site, a shop bot and this portfolio all live on one small ARM box. Here is the setup, and the mistakes that cost me an evening each.

Everything I run in production sits on one virtual server: Foliox License, DropVault, a shop bot, a couple of side projects and the site you’re reading. It’s an ARM machine running Ubuntu 24.04, and it costs less per month than one managed database on most platforms.

One box is not the right answer for everyone. But for a solo developer with a handful of small apps, it’s cheap, fast and easy to reason about. Here’s how mine is set up.

One app, one port, localhost only

Every app listens on its own port, bound to 127.0.0.1. Nothing but nginx can reach them from outside. If a framework defaults to 0.0.0.0, change it. A forgotten dev server open to the internet is how a lot of small servers get poked.

I keep a short list of which app owns which port in a text file on the server. It sounds silly until the day two apps fight over 3000.

pm2 keeps them alive

pm2 restarts an app when it crashes and brings everything back after a reboot. Each project gets an ecosystem file in the repo, so the process config is versioned with the code:

module.exports = {
  apps: [
    {
      name: 'mohitdev',
      script: 'dist/server/entry.mjs',
      node_args: '--env-file=.env',
      instances: 1,
      max_memory_restart: '400M',
      env: { NODE_ENV: 'production' },
    },
  ],
};

Run pm2 startup once, then pm2 save after every change to the process list, and the apps come back on their own after a reboot.

The environment variable trap

This one cost me an evening. pm2 remembers the environment from the moment a process was first started. And Node’s --env-file flag does not override variables that already exist. So you edit .env, restart, and the app keeps using the old value.

The fix is to load the file into the shell and tell pm2 to take the new environment:

set -a; . ./.env; set +a
pm2 restart mohitdev --update-env
pm2 save

nginx in front of everything

Each app gets its own file in /etc/nginx/sites-available, symlinked into sites-enabled. nginx handles TLS, compression, rate limits and the redirect from HTTP to HTTPS. The app only ever sees plain HTTP on localhost.

A minimal block looks like this:

upstream mysite_app {
    server 127.0.0.1:4810;
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://mysite_app;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Prefix your zone names

Rate limit zones and upstream names are global across the whole nginx config, not per file. Two apps that both define zone=login will clash, and nginx -t will refuse to load. I prefix everything with a short app code, like mk_login for this site.

Always run sudo nginx -t before systemctl reload nginx. A reload with a broken config does nothing, but a restart with one takes every site on the box down at once.

Certificates without letting certbot touch your config

certbot --nginx is handy for a first site. It’s less handy when your vhosts are hand-written, because it edits them for you, and with several server blocks it can attach the wrong certificate to the wrong one.

I use certonly instead. It proves you own the domain and saves the certificate, and leaves your config alone:

sudo certbot certonly --nginx -d example.com

Then I point the server block at the new files myself. Renewal still happens automatically.

Don’t forget www

When I moved this site over, the bare domain worked and www showed a certificate error. The DNS for www pointed at the server, and the port 80 redirect covered it. But nobody types http:// any more, and over HTTPS the browser checks the certificate before it will follow any redirect. www needs its own certificate and its own 443 block that redirects to the main domain. Test both names, over HTTPS, from outside the server.

Build native modules on the server

Packages like better-sqlite3, sharp and argon2 ship compiled code for one operating system and CPU. A node_modules folder built on a Windows laptop will not run on an ARM Linux server. Copy the source and the lockfile, then run npm ci on the server itself.

Deploys are one script

My deploy script does the same five things every time:

  1. Pack the source, without node_modules, .env or the data folder.
  2. Upload it and back up the database first.
  3. Run npm ci and the build on the server.
  4. pm2 reload the app.
  5. Request the home page and fail loudly if it doesn’t return 200.

The data folder never gets overwritten by a deploy. Neither does .env. Those two rules have saved me more than once.

Back up the boring way

Most of my apps use SQLite, so a backup is a single file. A cron job copies it every night and keeps the last 14. It’s not fancy, but it’s there when I need it.

When one box stops being enough

If an app needs to survive the server going down, or traffic outgrows one machine, it’s time to split things up. Until then, one well-kept server is easier to secure and cheaper to run than five services glued together.

If you want an app deployed like this, or your current setup is held together with tape, that’s the kind of work I do.

Written by Mohit Kamboj, a full-stack developer in Chandigarh, Punjab, India. Need something like this built? Tell me about it.