Posted in

How to Deploy a Node.js App on a VPS Step by Step

How to Deploy a Node.js App on a VPS Step by Step
How to Deploy a Node.js App on a VPS Step by Step
How to Deploy a Node.js App on a VPS Step by Step

Deploying a Node.js application to a VPS is the step where many self-taught developers stall. Platform-as-a-service tools hide the details, which is fine until you need a cheaper host, a background worker, or control over your own stack. This guide walks through the full process on a fresh Ubuntu server: user setup, runtime, process manager, reverse proxy, HTTPS and basic hardening.

What you need before starting

A VPS with a public IP running a recent Ubuntu LTS, root or sudo access, a domain name with DNS pointing at that IP, and an application that runs locally with a start script. One gigabyte of RAM is enough for a small Node app, though builds are more comfortable with two.

Point an A record for your domain at the server IP before you begin. DNS propagation takes time, and you will need it resolved by the certificate step.

Step 1: Connect and create a non-root user

ssh root@your-server-ip

adduser deploy
usermod -aG sudo deploy

Running applications as root means any compromise of your app is a compromise of the whole machine. Copy your SSH key across and log in as the new user from now on:

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
exit
ssh deploy@your-server-ip

Step 2: Update and install basics

sudo apt update && sudo apt upgrade -y
sudo apt install -y git curl ufw

Step 3: Configure the firewall first

Do this before exposing anything. The order matters — allow SSH before enabling, or you will lock yourself out.

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status

Note what is not here: port 3000. Your Node process will listen on localhost only, and Nginx will be the single public entry point.

Step 4: Install Node.js

Ubuntu’s default repository often carries an outdated version. Use NodeSource or nvm. For a server, NodeSource is simpler because it installs system-wide:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
node -v && npm -v

Step 5: Get your code onto the server

cd ~
git clone https://github.com/yourname/yourapp.git
cd yourapp
npm ci --omit=dev

Use npm ci rather than npm install on a server. It installs exactly what the lockfile specifies, which means the server runs the same dependency tree you tested locally. For a private repository, generate a deploy key on the server and add it to the repository settings rather than copying your personal SSH key.

Step 6: Environment variables

Never commit secrets. Create the file on the server:

nano .env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/appdb
SESSION_SECRET=a-long-random-string
chmod 600 .env

Setting NODE_ENV=production is not cosmetic. Frameworks including Express change behaviour based on it, enabling view caching and disabling verbose error output that would otherwise leak stack traces to users.

Make sure your app binds to localhost explicitly, so nothing reaches it except through the proxy:

app.listen(process.env.PORT || 3000, "127.0.0.1");

Step 7: Keep the process alive with PM2

Running node server.js in your terminal stops the moment you disconnect, and does not restart after a crash or reboot. PM2 solves all three.

sudo npm install -g pm2
pm2 start server.js --name yourapp
pm2 save
pm2 startup

The last command prints another command to run — copy and execute it. That registers PM2 as a systemd service so your app comes back after a reboot.

For a multi-core server, cluster mode runs one process per core and load-balances between them:

pm2 start server.js --name yourapp -i max

Useful day-to-day commands: pm2 logs yourapp, pm2 restart yourapp, pm2 monit, and pm2 list.

Systemd is a valid alternative if you prefer no extra dependency; PM2 simply packages the common needs more conveniently.

Step 8: Nginx as a reverse proxy

sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/yourapp
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;
    }
}
sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Always run nginx -t before reloading. It validates the configuration, and reloading a broken config takes your site offline.

The Upgrade and Connection headers are what allow WebSocket connections to pass through. Omit them and your realtime features will fail with an obscure handshake error.

If your app trusts client IPs for rate limiting or logging, tell it to read the forwarded header: app.set("trust proxy", 1) in Express.

Step 9: HTTPS with Let’s Encrypt

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

Certbot verifies domain ownership, obtains the certificate, rewrites your Nginx config to serve HTTPS, and sets up a redirect from HTTP. Renewal is handled by a systemd timer installed automatically. Confirm it works:

sudo certbot renew --dry-run

This step is why DNS needed to be correct first — the verification requires the domain to actually resolve to this server.

Step 10: Serve static files from Nginx

Node is not the fastest way to serve images and bundles. Hand them to Nginx:

location /static/ {
    alias /home/deploy/yourapp/public/;
    expires 30d;
    access_log off;
}

Enabling gzip in the main Nginx config gives a further easy win on text assets.

Step 11: Basic hardening

Disable password authentication and root login in /etc/ssh/sshd_config:

PermitRootLogin no
PasswordAuthentication no

Reload SSH with sudo systemctl reload sshd, and confirm you can still open a second session before closing the current one.

Install fail2ban to block repeated failed login attempts, and enable unattended security upgrades:

sudo apt install -y fail2ban unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Step 12: Deploying updates

A simple script covers most cases:

#!/bin/bash
set -e
cd /home/deploy/yourapp
git pull origin main
npm ci --omit=dev
npm run build
pm2 reload yourapp

Use reload rather than restart in cluster mode — it restarts workers one at a time, so requests are never dropped. The set -e line stops the script if any step fails, which prevents reloading a broken build.

Troubleshooting the usual failures

502 Bad Gateway. Nginx is running but cannot reach your app. Check pm2 list and confirm the port in your Nginx config matches the one the app actually listens on.

App crashes on start. Read pm2 logs yourapp --lines 100. Missing environment variables are the most frequent cause.

Permission denied on port 80. Ports under 1024 require root. Do not run Node as root to solve this — that is exactly what the reverse proxy is for.

Site works by IP but not by domain. DNS has not propagated, or server_name does not match the domain requested.

Out of memory during build. Add a swap file, or build the assets locally and deploy the output.

Logs, monitoring and knowing when something breaks

A deployed app without monitoring means your users find out about outages before you do. Three lightweight additions change that.

Log rotation. PM2 writes logs indefinitely, and an unbounded log file will eventually fill the disk, which takes down everything on the server including the database. Install the rotation module once:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7

A health endpoint. Add a route that returns 200 with minimal work — no database query, no external call — and point an uptime monitor at it. Free tiers of hosted monitors will check it every few minutes and email you when it fails.

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

Disk and memory checks. df -h and free -m take two seconds and reveal the two failure modes that most often kill small VPS deployments. Full disks in particular produce baffling errors, because everything from Nginx to npm starts failing for unrelated-looking reasons.

Beyond that, an error-tracking service that captures unhandled exceptions with stack traces is worth adding as soon as real users arrive. Reading logs manually stops scaling quickly.

Frequently asked questions

Do I need Docker for this?

No. Docker helps when you have multiple services or want identical environments across machines, but it adds a layer of concepts on top of everything above. Deploy directly first, then containerise once you understand what the container is replacing.

How much VPS do I need?

One or two gigabytes of RAM handles a small to medium Node app comfortably. Monitor with pm2 monit and scale when you see real pressure rather than in anticipation of it.

Where should the database live?

On the same server is fine for a small project and simplest to secure, since it can listen on localhost only. Move it to a managed service when backups, replication and point-in-time recovery start to matter more than cost.

How do I take backups?

Your code lives in Git. What needs backing up is the database and any uploaded files. Schedule a dump with cron, copy it off the server to object storage, and — this is the part people skip — restore it once to confirm the backup actually works.

Conclusion

The shape of a Node deployment is always the same: a non-root user runs the app on localhost under a process manager, Nginx terminates TLS and proxies to it, a firewall exposes only ports 22, 80 and 443, and updates arrive through a small repeatable script. Once you have done this once, every future deployment is the same sequence with different names, and the platform tools that used to feel like magic become a convenience rather than a necessity.

Leave a Reply

Your email address will not be published. Required fields are marked *