Deploy Node.js App to VPS: A No-Fluff Guide

by David Park

Most tutorials about deploying Node.js apps point you toward Heroku, Railway, or Render. Convenient? Sure. But you're paying a 3-5x markup for that convenience, and the moment your app gets any real traffic, the bill gets ugly fast.

I run three SaaS products on Hetzner VPS instances totaling $40/month. Each one is a Node.js app behind Nginx, managed by PM2, with Let's Encrypt SSL. It's not glamorous. It works. And when something breaks at 2am, I know exactly where to look.

This guide walks you through the full process to deploy a Node.js app to a VPS — from a blank Ubuntu 22.04 server to a live, process-managed, SSL-terminated app. No hand-waving, no "just use this button." Real commands that I actually run.

Choosing and Provisioning Your VPS

For a Node.js app serving under ~1,000 concurrent users, a 2 vCPU / 4GB RAM instance is plenty. On Hetzner, that's the CX22 at €3.79/month (as of mid-2025). DigitalOcean's equivalent is $18/month. You do the math.

Spin up Ubuntu 22.04 LTS. When the instance is live, SSH in as root:

ssh root@YOUR_SERVER_IP

First thing: create a non-root user and lock down SSH.

adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Then edit /etc/ssh/sshd_config and set:

PermitRootLogin no
PasswordAuthentication no

Restart SSH: systemctl restart sshd. Now reconnect as deploy. This is non-negotiable — root SSH access is how servers get owned.

Installing Node.js the Right Way

Don't use the Ubuntu apt repo for Node. It's almost always behind by several major versions. Use nvm so you can switch versions per project without pain.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
nvm alias default 20

Verify:

node -v   # should output v20.x.x
npm -v

Node 20 is the current LTS as of 2025. Use it unless you have a specific reason not to.

Getting Your App onto the Server

Two options: pull from Git directly, or rsync your build. I prefer Git for anything with a real deployment workflow.

sudo apt install git -y
mkdir -p ~/apps && cd ~/apps
git clone https://github.com/youruser/your-app.git
cd your-app
npm install --production

If your app needs a build step (TypeScript, for example):

npm install
npm run build

Now set your environment variables. Don't hardcode them, don't commit a .env file to the repo. Create it on the server:

nano ~/apps/your-app/.env

Add whatever your app needs:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
SESSION_SECRET=some-long-random-string

If you're using dotenv, it'll pick this up automatically. If not, export variables in your start script.

Process Management with PM2

Node.js dies when it crashes. PM2 brings it back. It also handles log rotation, clustering, and startup on reboot — all things you'd otherwise have to wire up yourself.

npm install -g pm2

Start your app:

pm2 start ~/apps/your-app/dist/index.js --name my-app

Or if you're using a plain JS entry point:

pm2 start ~/apps/your-app/index.js --name my-app

For environment variable loading, use an ecosystem file. This is the right way:

nano ~/apps/your-app/ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: './dist/index.js',
    instances: 'max',
    exec_mode: 'cluster',
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};

Then:

pm2 start ecosystem.config.js --env production
pm2 save
pm2 startup

That last command outputs a sudo command — copy it and run it. This registers PM2 as a systemd service so your app restarts after a server reboot.

Useful PM2 commands:

pm2 status          # see all running apps
pm2 logs my-app     # tail logs
pm2 reload my-app   # zero-downtime reload
pm2 monit           # live dashboard

The reload command is the one you'll use for deployments — it restarts workers one by one so there's no downtime.

Setting Up Nginx as a Reverse Proxy

Your Node app is running on port 3000. You don't want to expose that directly. Nginx sits in front, handles SSL termination, compression, and static file serving — things Node is slow at.

sudo apt install nginx -y
sudo systemctl enable nginx

Create a config for your site:

sudo nano /etc/nginx/sites-available/my-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;
    }

    # Serve static files directly if you have them
    location /static/ {
        alias /home/deploy/apps/your-app/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Enable it:

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

If nginx -t throws errors, fix them before reloading. Common mistake: forgetting the semicolons or mismatching the server_name.

SSL with Let's Encrypt (Certbot)

HTTPS is not optional in 2025. Certbot makes it free and mostly automatic.

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

Follow the prompts. Certbot will modify your Nginx config to add the SSL block and redirect HTTP to HTTPS automatically. After it's done, your config will have listen 443 ssl sections added.

Verify auto-renewal works:

sudo certbot renew --dry-run

Certbot installs a systemd timer that handles renewal automatically. You shouldn't have to touch it again.

Deployment Workflow: Updating Your App

Once everything's running, here's the workflow for pushing updates:

cd ~/apps/your-app
git pull origin main
npm install --production
npm run build          # if you have a build step
pm2 reload my-app

That's it. Four commands. If you want to automate this, write a deploy script:

nano ~/deploy.sh
#!/bin/bash
set -e

cd ~/apps/your-app
echo "Pulling latest code..."
git pull origin main

echo "Installing dependencies..."
npm install --production

echo "Building..."
npm run build

echo "Reloading PM2..."
pm2 reload my-app

echo "Done. App is live."
chmod +x ~/deploy.sh

Now you can trigger deploys with a single ./deploy.sh from your local machine via SSH:

ssh deploy@YOUR_SERVER_IP 'bash ~/deploy.sh'

Plug that into a GitHub Actions workflow and you've got basic CI/CD without paying for a deployment platform.

Firewall and Basic Hardening

Before you call this done, lock down the firewall. UFW makes this straightforward:

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

This blocks everything except SSH and HTTP/HTTPS. Your Node app on port 3000 is now only accessible through Nginx — not directly from the internet.

Also install fail2ban to block brute-force SSH attempts:

sudo apt install fail2ban -y
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Default config is fine for most cases. It watches /var/log/auth.log and bans IPs after 5 failed attempts.

Quick Comparison: VPS vs. Platform-as-a-Service

VPS (Hetzner CX22) Railway Starter Render Starter
Monthly cost ~€4 ($4.30) $5 + usage $7 + usage
RAM 4 GB 512 MB 512 MB
Custom domains Yes (free) Yes Yes
SSL Free (Certbot) Included Included
SSH access Full No No
Cold starts No Yes (free tier) Yes (free tier)
Vendor lock-in None Medium Medium
Setup time ~30 min ~5 min ~5 min

PaaS wins on setup speed. VPS wins on everything else once you're past the learning curve — which this guide just flattened for you.

Conclusion: What to Do Tomorrow

Deploying a Node.js app to a VPS isn't complicated once you've done it once. The stack is boring on purpose: Ubuntu + Node (via nvm) + PM2 + Nginx + Certbot. Each piece does one job. Nothing is magic, so nothing breaks mysteriously.

If you're currently paying $20-50/month on a PaaS for a side project, spin up a Hetzner CX22 this week. Follow this guide. You'll have your Node.js app deployed to a VPS in under an hour, and you'll cut your hosting bill by 70-80%.

Next step: set up automated backups for your database before anything else. A running app with no backups is just a ticking clock.

Want to go further? Check out how to set up a PostgreSQL database on the same VPS to keep your stack self-contained, or read up on monitoring your VPS with Netdata so you know when something's wrong before your users do.