← All writing
DevOps

From localhost to Live: Deploying Node.js Apps on a VPS

Platform hosting is wonderful until the invoice or the limits arrive. A plain VPS with nginx, pm2, and certbot remains the most durable way to ship client work — here is the full recipe.

Every project I hand off answers the same three questions: what starts the app, what routes traffic to it, and what happens when the box reboots. On a VPS, the answers are pm2, nginx, and pm2 again. This post expands the recipe into every command, from a fresh Ubuntu image to a certificate that renews itself — in the order you'd actually run them.

Minute zero: a fresh box, hardened

Before any Node touches the server, three habits close the doors nobody should be knocking on:

# As root, once: create the user you'll actually work as
adduser deploy
usermod -aG sudo deploy
rsync -a ~/.ssh /home/deploy/ && chown -R deploy:deploy /home/deploy/.ssh

# Firewall: 22, 80, 443 — and nothing else
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable

# Turn off password logins entirely — keys only
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh

Note what's not open: 3000 (Node's port) and 27017 (MongoDB's). Node will only ever be reached through nginx, and MongoDB stays bound to 127.0.0.1 — the internet does not need to meet your database. Verify the binding after installing it:

# /etc/mongod.conf
net:
  bindIp: 127.0.0.1   # the default — confirm nobody "fixed" it to 0.0.0.0
  port: 27017

The stack

Node via nvm, pinned by the repo

# As the deploy user
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc

# The repo carries its own Node version
echo "20" > .nvmrc

nvm install    # reads .nvmrc
nvm use        # every terminal and deploy script now agrees

The .nvmrc is the whole point: your laptop, the server, and any future maintainer resolve the version from the same file, and "works on my machine" loses its favourite cause.

pm2: start, crash-restart, reboot-resurrect

You can start apps ad hoc (pm2 start npm --name blog -- start), but an ecosystem file makes the setup declarative and repeatable — one file describes every app on the box:

// ecosystem.config.js — lives in the repo
module.exports = {
  apps: [
    {
      name: 'blog',
      cwd: '/home/deploy/apps/blog',
      script: 'npm',
      args: 'start',            // Next.js: next start
      env: { NODE_ENV: 'production', PORT: 3000 },
      max_memory_restart: '400M', // restart before the box starts swapping
    },
    {
      name: 'store-api',
      cwd: '/home/deploy/apps/store-api',
      script: 'server.js',
      env: { NODE_ENV: 'production', PORT: 3001 },
      instances: 2,             // cluster mode: use both cores
      exec_mode: 'cluster',
    },
  ],
};
pm2 start ecosystem.config.js
pm2 startup    # prints ONE command — run it; it wires pm2 into systemd
pm2 save       # snapshot the process list; this is what reboot restores

Those last two lines are the answer to "what happens when the box reboots." pm2 startup registers pm2 with systemd; pm2 save records what to bring back. Miss either one and your app survives every crash except the power cut.

Day-to-day, three commands cover operations:

pm2 status          # what's running, memory, restarts
pm2 logs blog       # live stdout/stderr — your first stop when something's wrong
pm2 reload blog     # zero-downtime restart (cluster mode rolls one instance at a time)

nginx: the block, grown up

The minimal proxy block works; this is the version that ships, with the headers WebSockets and real-IP logging need, and static assets served without waking Node:

# /etc/nginx/sites-available/blog
server {
  server_name example.com www.example.com;

  # Next.js immutable build assets: let nginx serve and cache them
  location /_next/static/ {
    alias /home/deploy/apps/blog/.next/static/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    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;

    # WebSockets / HMR / anything using Upgrade
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }

  client_max_body_size 10m;  # or the first image upload returns a mystery 413
}

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/
nginx -t              # ALWAYS test before reload — a typo here downs every site
systemctl reload nginx

certbot: TLS you never think about again

apt install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com

Certbot edits the nginx block in place — adds the 443 listener, the certificate paths, and the HTTP→HTTPS redirect — and installs a systemd timer that renews well before expiry. Confirm the timer once and forget it exists:

systemctl list-timers | grep certbot
certbot renew --dry-run   # rehearse the renewal; if this passes, you're done

Environment variables: loaded, never committed

Secrets live in a file on the server, outside the repo, readable only by the deploy user:

# /home/deploy/apps/blog/.env  (chmod 600, listed in .gitignore)
MONGODB_URI=mongodb://127.0.0.1:27017/blog
SESSION_SECRET=…
REVALIDATE_SECRET=…

Next.js and most frameworks read .env from the app directory on their own. For a bare Express app, load it in the ecosystem file rather than sprinkling dotenv calls through the code:

// ecosystem.config.js
{
  name: 'store-api',
  script: 'server.js',
  env_file: '/home/deploy/apps/store-api/.env',
}

One habit worth the paranoia: after changing an env var, pm2 restart store-api --update-env — plain restart reuses the old environment, and you will spend an hour discovering that.

Backups: the cron'd mongodump

A backup on the same disk as the database is a rehearsal, not a backup. Dump nightly, compress, prune old ones, and ship the result somewhere else:

#!/bin/bash
# /home/deploy/bin/backup-mongo.sh
set -euo pipefail

STAMP=$(date +%F)
DIR=/home/deploy/backups
mkdir -p "$DIR"

mongodump --db blog --archive="$DIR/blog-$STAMP.gz" --gzip

# Keep 14 days locally
find "$DIR" -name 'blog-*.gz' -mtime +14 -delete

# Off-box copy — object storage, another VPS, anywhere that isn't this disk
rclone copy "$DIR/blog-$STAMP.gz" b2:client-backups/blog/
chmod +x /home/deploy/bin/backup-mongo.sh
crontab -e
# 03:30 nightly
30 3 * * * /home/deploy/bin/backup-mongo.sh >> /home/deploy/backups/backup.log 2>&1

And once — ideally before you need it — rehearse the restore, because an untested backup is a hope:

mongorestore --db blog_restore_test --archive=blog-2026-04-28.gz --gzip

Deploys: one script, no typing at midnight

Everything above is setup you do once. Deploys are the thing you do weekly, so make them a single command that cannot skip a step:

#!/bin/bash
# /home/deploy/bin/deploy-blog.sh
set -euo pipefail

cd /home/deploy/apps/blog

git pull origin main
nvm use                    # obey .nvmrc
npm ci                     # clean, lockfile-exact install
npm run build              # build BEFORE touching the running app
pm2 reload blog            # zero-downtime swap
pm2 save

echo "Deployed $(git rev-parse --short HEAD) at $(date)"

The ordering carries the safety: set -e aborts on any failure, and because the build runs before the reload, a broken build leaves the old version serving traffic instead of taking the site down with it.

The checklist that prevents the 2am call

  • pm2 save after every process change, or the reboot forgets your app. pm2 startup once, pm2 save always.
  • Environment variables in a file pm2 loads — never hardcoded, never committed, and --update-env when they change.
  • A cron'd mongodump to somewhere that is not the same disk — with one rehearsed restore on record.
  • UFW open on 22, 80, 443 — and nothing else. Node and MongoDB talk to nginx and the app over localhost only.
  • nginx -t before every reload. The five seconds it takes is cheaper than every alternative.

Platforms sell you convenience; a VPS sells you understanding. For long-lived client projects, understanding compounds better.

#deployment#nginx#pm2#vps
AA
Written byAmanat Ali

A full-stack developer who builds websites end-to-end — Next.js, Node.js, and MongoDB — and writes buildlog in the margins.