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 saveafter every process change, or the reboot forgets your app.pm2 startuponce,pm2 savealways.- Environment variables in a file pm2 loads — never hardcoded, never committed, and
--update-envwhen they change. - A cron'd
mongodumpto 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 -tbefore 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.