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

by David Park

Most tutorials tell you to push to Heroku or Railway and call it a day. That's fine until your bill hits $50/month for a side project making $0. If you want real control and real savings, you deploy Node.js app to VPS yourself. It's not as scary as it sounds.

I've been doing this for nine years. The setup I'm about to walk you through runs three of my own SaaS products on Hetzner for a combined $40/month — Node.js backends, Postgres, the works. Once you've done it once, you'll wonder why you ever paid platform markups.

This guide covers everything: provisioning a VPS, getting your Node app running, keeping it alive with PM2, serving it through Nginx, and locking it down with a free SSL cert. Let's get into it.

Pick Your VPS and Provision It

I use Hetzner CX22 (2 vCPU, 4 GB RAM, €4.35/month as of mid-2025). DigitalOcean and Vultr work too, but Hetzner gives you more hardware per euro. For a Node.js app with moderate traffic, 2 GB RAM is the floor — don't go lower.

Create an Ubuntu 24.04 LTS server. When Hetzner (or your provider) asks for SSH keys, add yours. Don't rely on password auth — it's a liability.

Once it's up, SSH in:

ssh root@YOUR_SERVER_IP

First thing: create a non-root user.

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

Log out, log back in as deploy. You should never run your Node app as root.

Install Node.js the Right Way

Don't apt install nodejs. The Ubuntu repo ships ancient versions. Use nvm or the NodeSource binary.

I prefer NodeSource because it's system-wide and plays nicer with PM2 service restarts:

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

Verify:

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

Node 22 is the current LTS as of 2025. Use it. Older versions lose security patches.

Get Your App onto the Server

Two real options: Git pull or rsync. I use Git for anything I care about.

On your server:

mkdir -p /var/www/myapp
cd /var/www/myapp
git clone https://github.com/youruser/yourapp.git .

If the repo is private, use a deploy key or a personal access token. Don't hardcode credentials.

Install dependencies — production only:

npm ci --omit=dev

npm ci is faster and deterministic. --omit=dev skips devDependencies, which shaves memory.

Environment Variables

Never commit .env to Git. On the server, create it manually:

nano /var/www/myapp/.env

Add your secrets:

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

Set strict permissions so other users can't read it:

chmod 600 /var/www/myapp/.env

Run It With PM2 (Not node server.js)

Running node server.js directly means your app dies the moment you close the terminal — or whenever it crashes. PM2 is the process manager that fixes both problems.

Install it globally:

sudo npm install -g pm2

Start your app:

cd /var/www/myapp
pm2 start server.js --name myapp

Tell PM2 to restart on reboot:

pm2 startup
# PM2 prints a command — copy-paste and run it
pm2 save

Useful PM2 commands you'll actually use:

pm2 logs myapp          # tail logs
pm2 restart myapp       # restart after a deploy
pm2 status              # see all running processes
pm2 monit               # live CPU/memory dashboard

One thing people miss: PM2 loads .env automatically if you use the --env flag or an ecosystem file. Here's a minimal ecosystem.config.js that I keep in every project:

module.exports = {
  apps: [{
    name: 'myapp',
    script: 'server.js',
    instances: 'max',          // cluster mode — uses all CPUs
    exec_mode: 'cluster',
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};

Start with:

pm2 start ecosystem.config.js --env production

Cluster mode is free performance. On a 2-vCPU box, you get two Node processes handling requests in parallel.

Put Nginx in Front

Your Node app listens on port 3000. You don't want to expose that directly. Nginx sits in front, handles SSL termination, serves static files fast, and lets you run multiple apps on one server.

Install:

sudo apt install -y nginx

Create a site config:

sudo nano /etc/nginx/sites-available/myapp

Paste this:

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

    location / {
        proxy_pass http://127.0.0.1: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 — don't waste Node on this
    location /static/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Enable it:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t          # test config — fix errors before reloading
sudo systemctl reload nginx

The proxy_set_header X-Forwarded-For line is important. Without it, req.ip in Express always shows 127.0.0.1 instead of the real client IP. If you use Express, also add:

app.set('trust proxy', 1);

Add Free SSL With Certbot

No excuse to skip HTTPS. Certbot + Let's Encrypt is free and takes under two minutes.

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

Certbot modifies your Nginx config automatically — adds the SSL block, redirects HTTP to HTTPS, and schedules auto-renewal via a systemd timer. Verify renewal works:

sudo certbot renew --dry-run

If that passes, you're done. Your cert renews every 60 days without you touching anything.

Firewall: Lock It Down

Your VPS is exposed to the internet the moment it boots. UFW (Uncomplicated Firewall) is your first line of defense.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'   # allows 80 and 443
sudo ufw enable

Check status:

sudo ufw status

Port 3000 should NOT appear in the output. Only SSH and Nginx should be open. Your Node app is only reachable through Nginx, which is exactly what you want.

Deploying Updates

Once the initial setup is done, deploying updates is fast. I use a simple shell script:

#!/bin/bash
# deploy.sh — run this on the server
set -e

cd /var/www/myapp
git pull origin main
npm ci --omit=dev
pm2 restart myapp
echo "Deploy done."

Make it executable:

chmod +x deploy.sh

From your local machine, you can trigger it over SSH:

ssh deploy@YOUR_SERVER_IP '/var/www/myapp/deploy.sh'

That's a one-liner deploy. No CI/CD vendor, no webhook complexity — just SSH and a shell script. For solo projects, this is plenty. If you want zero-downtime deploys, look into PM2's reload command (for cluster mode) instead of restart — it cycles workers one at a time.

Comparison: VPS vs. Managed Platforms

VPS (Hetzner CX22) Railway Starter Render Starter Fly.io (2x shared)
Monthly cost ~€4.35 ~$5 (then $20+) $7 ~$5–$10
RAM 4 GB 512 MB 512 MB 256 MB
Full OS access Partial
Custom Nginx config
Multiple apps on one server Extra cost Extra cost Extra cost
Cold starts None Yes (free tier) Yes (free tier) Minimal
Setup time ~30 min ~5 min ~5 min ~15 min

The managed platforms win on setup time. That's real. But you're paying 3-5x the price for a fraction of the resources. Once you've done the VPS setup once, you can replicate it in 30 minutes with a script. The time cost amortizes fast.

Monitoring: Don't Fly Blind

PM2 gives you basic monitoring with pm2 monit. For something persistent, I run Netdata on the server — it's free, installs in one command, and gives you CPU, RAM, disk, and network graphs with no account required for self-hosted.

wget -O /tmp/netdata-kickstart.sh https://get.netdata.cloud/kickstart.sh
sudo sh /tmp/netdata-kickstart.sh

Access it at http://YOUR_SERVER_IP:19999 — but only after you've added a firewall rule temporarily, or tunneled via SSH:

ssh -L 19999:localhost:19999 deploy@YOUR_SERVER_IP

Then open http://localhost:19999 in your browser. No port exposed, no risk.

For uptime alerts, I use UptimeRobot free tier — it pings your domain every 5 minutes and emails you if it goes down. Zero config, zero cost.

Conclusion

To deploy a Node.js app to VPS, the full stack is: Ubuntu 24.04 + NodeSource Node 22 + PM2 in cluster mode + Nginx reverse proxy + Certbot SSL + UFW firewall. That's it. No magic, no platform dependency, no surprise invoices.

Your action for tomorrow: spin up a Hetzner CX22, follow this guide top to bottom, and get your first Node app running in production. The whole thing takes about 30-45 minutes the first time. After that, deploys are a one-liner.

If you want to take this further, check out this guide on setting up Postgres on your VPS and configure automatic backups so you're not one DROP TABLE away from disaster.