All blog

Deploying a Node.js App to Production on a VPS: The Complete Guide

A step-by-step guide to taking a Node.js app from a fresh Ubuntu VPS to a hardened, HTTPS-secured, self-healing production service — non-root user, firewall, PM2 cluster mode, an Nginx reverse proxy, and auto-renewing SSL.

DevOps & CloudStephen NdegwaPublished 8 min read
Share
Deploying a Node.js App to Production on a VPS: The Complete Guide

Getting a Node.js app running on your laptop is easy. Getting it to run reliably on a server that strangers on the internet depend on — surviving crashes, reboots, traffic spikes, and your own 2 a.m. deploys — is a different skill entirely. This guide walks through every step of taking a Node.js application from a fresh Ubuntu VPS to a hardened, HTTPS-secured, self-healing production service.

We keep it practical and vendor-neutral: the commands work on any Ubuntu 22.04/24.04 server. Where hosting choices matter — like picking a region close to your users — we call it out, because for African traffic that single decision often does more for perceived speed than any amount of code optimisation.

What you’ll build

By the end you’ll have a stack that looks like this:

  • Your Node.js app running as an unprivileged user, never as root.
  • PM2 keeping it alive — restarting it on crashes and on server reboot, and running one worker per CPU core.
  • Nginx in front as a reverse proxy, terminating TLS and shielding Node from the raw internet.
  • Let’s Encrypt certificates that renew themselves.
  • A firewall that only lets through the ports you actually use.

It’s the same shape whether you’re running an Express API, a Next.js server, a NestJS backend, or a Fastify service.

Before you start

You’ll need three things:

  • A VPS running Ubuntu with root or sudo access.
  • A domain name you can point at the server.
  • Your app in a Git repository (GitHub, GitLab, or self-hosted).

Tip: latency is a feature. A user in Nairobi talking to a server in Frankfurt pays 150 ms per round trip before your code runs at all. The same user hitting a server in Nairobi pays single-digit milliseconds. If your audience is in East, West, or Central Africa, provision in a region close to them — it’s the cheapest performance win you’ll ever make.

Step 1: Provision and access your VPS

Spin up an Ubuntu instance. For most production web apps, start with 2 vCPU and 4 GB of RAM — enough headroom for Node, Nginx, and a small database or cache — and scale up later if you need to. When you choose a location, pick the region nearest your users rather than the cheapest one on the list.

Once it’s running, connect over SSH using the IP address from your provider:

ssh [email protected]

First thing, update the system so you start from a patched baseline:

apt update && apt upgrade -y

Step 2: Create a non-root user and lock down SSH

Running your app — or even your shell — as root means any mistake or compromise has the keys to the whole machine. Create a normal user with sudo rights and work as them:

adduser deploy
usermod -aG sudo deploy

Copy your SSH key to the new user so you can log in as them, then switch over:

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
su - deploy

Now harden the SSH daemon. Open /etc/ssh/sshd_config and set:

PermitRootLogin no
PasswordAuthentication no

This forces key-based login and blocks the single most common attack — automated password brute-forcing against root. Reload SSH, but keep your current session open until you’ve confirmed a new login works:

sudo systemctl reload ssh

Step 3: Set up a firewall

Ubuntu ships with ufw, an approachable front-end to the kernel firewall. The rule of thumb: deny everything, then allow only what you need. For a web server that’s SSH plus HTTP and HTTPS.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Notice we never open port 3000 (or whatever your app listens on) to the world. Node will only be reachable through Nginx, which is exactly what we want.

Step 4: Install Node.js with nvm

Don’t install Node from Ubuntu’s default repositories — the version is usually years out of date. Use nvm (Node Version Manager) so you can install any version and switch cleanly later:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
node --version

Installing the LTS release gets you the version with the longest support window — the right default for anything you don’t want to babysit.

Step 5: Get your application onto the server

Clone your repository into the deploy user’s home directory:

cd ~
git clone https://github.com/your-org/your-app.git
cd your-app
npm ci --omit=dev
npm run build

Use npm ci rather than npm install in production: it installs exactly what’s in your lockfile, reproducibly, and fails loudly if the lockfile and package.json disagree.

Handle configuration and secrets properly

Never commit API keys, database passwords, or tokens to your repository. Keep them in an environment file on the server that is readable only by the deploy user:

nano ~/your-app/.env
chmod 600 ~/your-app/.env

Load them from process.env in your code, and make sure .env is listed in your .gitignore. Your secrets live on the server, never in version control.

Step 6: Keep your app alive with PM2

If you start your app with node server.js and close your terminal, it dies. If it throws an unhandled exception, it dies. PM2 solves both: it’s a process manager that restarts your app on crashes, runs it in the background, and brings it back after a reboot.

npm install -g pm2
pm2 start npm --name "your-app" -- start

Run one worker per CPU core

Node is single-threaded, so a single process only uses one core. On a multi-core VPS, run PM2 in cluster mode to use all of them and load-balance requests across the workers:

pm2 start npm --name "your-app" -i max -- start

The -i max flag tells PM2 to spawn as many workers as you have cores. Your throughput scales with the box.

Start automatically on boot

A server reboots — for kernel updates, or because the host had a hiccup. Make PM2 (and therefore your app) come back on its own:

pm2 startup systemd
pm2 save

The first command prints a line to copy-paste; the second saves your current process list so it’s restored on boot.

Step 7: Put Nginx in front as a reverse proxy

Exposing Node directly to the internet is a mistake — it’s not built to be an edge server. Nginx sits in front, accepts requests on ports 80/443, and forwards them to Node on localhost. It also handles TLS, gzip, static files, and rate limiting far better than Node can.

sudo apt install nginx -y

Create a site config at /etc/nginx/sites-available/your-app:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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;
        proxy_cache_bypass $http_upgrade;
    }
}

The Upgrade and Connection headers matter if your app uses WebSockets. The X-Forwarded-* headers pass the real client IP and protocol through to Node — without them, every request looks like it came from localhost.

Enable the site and reload:

sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Always run nginx -t before reloading — it validates the config so a typo can’t take your site down.

Step 8: Add HTTPS with Let’s Encrypt

HTTPS is non-negotiable in production — for security, for user trust, and because browsers and search engines penalise plain HTTP. Certbot makes it free and automatic:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot edits your Nginx config to serve HTTPS, sets up an automatic HTTP → HTTPS redirect, and installs a renewal timer. Confirm the auto-renewal works with a dry run:

sudo certbot renew --dry-run

Your certificate now renews itself every 90 days with no action from you.

Step 9: Ship updates with zero downtime

When you deploy a new version, you want users mid-request to never see an error. PM2’s reload does a rolling restart — it brings up new workers before killing the old ones:

cd ~/your-app
git pull origin main
npm ci --omit=dev
npm run build
pm2 reload your-app

Note reload, not restart: restart kills every worker at once (a brief outage), while reload swaps them one at a time. Wrap those five lines in a deploy.sh script and updates become a single command.

Step 10: Logs, monitoring, and backups

You can’t fix what you can’t see. PM2 keeps per-app logs and a live dashboard:

pm2 logs your-app
pm2 monit

Three habits will save you real pain:

  • Watch your logs. Route application errors somewhere you’ll actually look — even pm2 logs piped to a file and checked daily beats silence.
  • Alert on the box, not just the app. Track CPU, memory, and disk. A full disk is the classic 3 a.m. outage, and it’s completely preventable.
  • Back up, and test the restore. Automate database and file backups, store them off the server, and actually restore one occasionally. A backup you’ve never restored is a hope, not a plan.

Common mistakes to avoid

  • Running as root. Every command in this guide runs as an unprivileged user for a reason — it limits the blast radius of any mistake or breach.
  • Exposing Node directly. Always keep a reverse proxy in front. Node’s built-in server isn’t hardened for the open internet.
  • Committing secrets. Keys belong in a .env file with 600 permissions on the server, never in the repository.
  • Skipping the firewall. “I’ll add it later” is how databases end up publicly reachable. Deny by default from day one.
  • Hosting far from your users. No amount of caching undoes an ocean of network latency. Put the server near the people using it.

Wrapping up

You now have a production Node.js deployment that survives crashes and reboots, serves traffic over HTTPS, uses every CPU core, and only exposes the ports it needs. That’s the same architecture running behind serious production apps — the difference is just scale.

The one decision no amount of configuration can fix after the fact is where your server lives. If your users are in Africa, hosting locally — in Nairobi, Dar es Salaam, or Lagos — turns a sluggish app into a snappy one before you write a single line of optimisation. Spin up a Lineserve cloud server close to your users and put this guide to work.

#Deployment#Nginx#Node.js#PM2#Ubuntu#VPS