Muhammed Shibli

Backend · Web

Deploying a Node App on a VPS: Nginx, PM2, and SSL From Scratch

· by Muhammed Shibli

Deploying a Node.js app to a VPS properly means three things working together: a process manager that keeps your app alive, nginx sitting in front of it as a reverse proxy, and SSL that renews itself without anyone remembering to do it manually. This is the exact sequence I use for client backends, stripped to what actually matters.

The stack I actually use

Ubuntu as the base OS (22.04 or 24.04, whatever the provider offers current), Node.js via a version manager rather than the distro's default package (which is often outdated), PM2 for process management, nginx as the reverse proxy and SSL terminator, and certbot for Let's Encrypt certificates. Nothing exotic — this combination is boring, well-documented, and exactly why I trust it for client work where "boring and reliable" beats "clever and fragile."

Step 1: the server and the app

After the usual first-day hardening (non-root deploy user, SSH key auth, ufw allowing only SSH and HTTP/HTTPS), get Node installed and your app's code onto the server — a git clone or a deploy script, either works. Install dependencies, set environment variables through a .env file kept out of version control, and confirm the app runs with a plain node server.js before adding any process management on top. Getting a clean manual run first makes it much easier to tell whether a later problem is your app or your infrastructure.

Step 2: PM2 for process management

This is the step people skip when they're in a hurry, and it's the one that causes 2am outages. A bare node server.js & or a screen session dies the moment the SSH connection drops or the server reboots, and nothing brings it back.

npm install -g pm2
pm2 start server.js --name my-app
pm2 save
pm2 startup

pm2 startup prints a command you need to run once (it generates a systemd service tied to your user) — that's the step that makes PM2 survive a full server reboot, not just an app crash. pm2 save after starting your app ensures the process list persists across that reboot too. Skip either step and PM2 restarts your app on crash while the server's running, but a reboot still leaves you with nothing.

Step 3: nginx as reverse proxy

nginx sits in front of Node and handles the public-facing side — port 80/443 — while your Node app listens privately on something like port 3000. The config block:

server {
    listen 80;
    server_name api.example.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_cache_bypass $http_upgrade;
    }
}

The Upgrade/Connection headers matter if your app uses WebSockets — easy to forget and the symptom is confusing (WebSocket connections silently fail while regular HTTP works fine). proxy_set_header X-Real-IP and Host matter if your app needs to know the real client IP or original hostname, since without them your app only sees nginx's local connection details.

Symlink this into sites-enabled, run nginx -t to validate the config before reloading (this one habit has saved me from taking a server offline with a typo more times than I'd like to admit), then systemctl reload nginx.

Step 4: SSL with certbot

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

Certbot's nginx plugin edits your server block automatically to add the SSL configuration and sets up an HTTP-to-HTTPS redirect. Behind the scenes it installs a renewal timer (systemd timer on modern Ubuntu, cron on older setups) that checks certificate expiry twice daily and renews automatically inside the 30-day window before expiry. Let's Encrypt certs are valid 90 days, so this needs to actually work — I always run sudo certbot renew --dry-run right after setup to confirm the renewal mechanism functions, rather than assuming it does and finding out three months later when the cert silently expires.

Logs and a basic health check

Once the app is live, two more things earn their setup time. pm2 logs my-app gives you a live tail without hunting for log files, but on a long-running production app the logs will grow unbounded unless you install pm2-logrotate (pm2 install pm2-logrotate), which caps file size and retention automatically. I skipped this on an early client deployment and found the disk quietly filling up months later — an easy problem to prevent, an annoying one to notice after the fact.

A basic health check endpoint (GET /health returning a simple 200 with uptime and a database ping) costs a few lines of code and pays for itself the first time something goes wrong. Pointing an external uptime monitor at it — even a free one checking every few minutes — means you find out about downtime from an alert, not from a client message asking why the app isn't responding.

What breaks if you skip a step

Skip PM2's startup step and a server reboot (planned maintenance, an unexpected restart) takes your app down until someone manually starts it again — I've seen this cause hours of downtime on client bots because nobody noticed until a user complained. Skip the nginx config validation step and a typo in a config change can take down every site on that server, not just the one you were editing, because nginx won't reload at all with an invalid config. Skip the certbot dry-run and you won't know the renewal mechanism is broken until your certificate actually expires and browsers start showing security warnings to your users — which is a much worse way to discover the problem than a five-second dry-run command.

None of these are hard to get right. They're just easy to skip when you're focused on getting the app running and treating the infrastructure around it as an afterthought — which is exactly backwards, since the infrastructure is what keeps the app running after you stop watching it.

For how this fits into a full backend build, see full stack development — I use this exact deployment pattern for client backends, Telegram bots, and this site's own infrastructure.

Frequently asked questions

Why use nginx in front of a Node.js app instead of exposing Node directly?

A few reasons that all matter in production: nginx handles SSL termination so your Node process doesn't need to deal with certificates directly, it can serve static files faster than Node would, and it gives you a stable place to add rate limiting, gzip compression, or route multiple apps on one server by domain or path — all without touching application code.

What does PM2 actually do that a plain node command doesn't?

Keeps the process alive. A bare `node server.js` dies the moment your SSH session closes unless you're careful, and it doesn't restart itself if the app crashes. PM2 daemonizes the process, restarts it automatically on crash, gives you log management, and with `pm2 startup` configured, brings your app back up automatically if the server itself reboots.

How often does an SSL certificate from certbot need renewal?

Let's Encrypt certificates are valid for 90 days, but certbot installs a renewal mechanism (usually a systemd timer or cron job) that checks twice daily and renews automatically once a certificate is within 30 days of expiry. If you set it up correctly once, you should never need to manually renew — but it's worth verifying the timer is actually active rather than assuming it is.

Can I run multiple apps on one VPS this way?

Yes, and it's one of the more cost-effective things about this setup. Each app gets its own PM2 process and its own nginx server block, keyed to a different domain or subdomain, all sharing the same underlying VPS resources. I run several client sites and bots this way on shared infrastructure when it makes sense for the client's budget.

Related reading

Keep going