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.