Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Hosting

How to deploy a Node.js app on a VPS

A complete walkthrough for putting a Node.js app into production on your own VPS. You'll use Git, nvm, pm2, Nginx, and free Let's Encrypt SSL.

How to deploy a Node.js app on a VPS

By the end of this guide you'll have a Node.js app running on your own VPS, kept alive by pm2, sitting behind Nginx, reachable on your domain over HTTPS. It's written for someone who can use a terminal and has a Linux server (Ubuntu or Debian) but has never put a Node app into production before. We run apps this way ourselves, so the steps reflect what actually works rather than the bare minimum.

Before you start, you'll want a few things ready. A VPS you can reach over SSH. A non-root user with sudo rights (logging in as root all day is a bad habit). And a Git repository for your app, on GitHub or anywhere else. If you have those, you're good.

Step 1: Connect and update the server

Log in over SSH. Swap in your own user and IP address.

ssh [email protected]

The first thing to do on any fresh box is update the package list and apply pending upgrades. Skipping this is how you end up debugging a problem that was already fixed weeks ago.

sudo apt update
sudo apt upgrade -y

While you're here, install Git if it isn't already present. Most images ship with it, but check.

sudo apt install -y git
git --version

You should see something like git version 2.34.1. Any recent version is fine.

Step 2: Install Node with nvm

You could install Node from the system package manager, but the version there is often old, and switching versions later is a pain. We prefer nvm (Node Version Manager). It installs Node in your home directory and lets you run any version you want without touching the rest of the system.

Grab the install script and run it. Check the nvm GitHub page for the latest version number if you want, the one below works.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

The script edits your shell profile, but that change only applies to new shells. So either log out and back in, or load it now.

source ~/.bashrc

Now install a Node version. The LTS (long term support) release is the safe choice for production. Install it and tell nvm to use it by default.

nvm install --lts
nvm alias default lts/*

Confirm both Node and npm are there.

node -v
npm -v

You'll get back something like v20.17.0 and 10.8.2. Exact numbers don't matter much, as long as the major version matches what your app expects.

Step 3: Get your code onto the server

The cleanest way to move code is Git. Clone your repo into a sensible spot. We like keeping apps under the user's home folder so you don't need sudo to manage them.

cd ~
git clone https://github.com/yourname/your-app.git
cd your-app

If the repo is private, you'll be asked for credentials, or you can set up an SSH deploy key. A read only deploy key is the tidy option for a server, since it can pull but can't push.

Once the code is there, install the dependencies. Use npm ci rather than npm install when you have a package-lock.json. It installs exactly what the lock file says, which means the server runs the same versions you tested locally.

npm ci

If your app has a build step (a TypeScript compile, a frontend bundle), run it now.

npm run build

Step 4: Set environment variables in a.env file

Hardcoding secrets in your code is asking for trouble. Use a .env file instead and read it at startup. Create the file in your app folder.

nano.env

Put your real values in, one per line. Here's the shape of it.

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://appuser:secret@localhost:5432/appdb
SESSION_SECRET=change-this-to-something-long-and-random

Save and close (in nano that's Ctrl+O, Enter, then Ctrl+X). Two important habits here. First, make sure .env is listed in your .gitignore so it never gets committed. Second, lock down the file so other users on the box can't read your secrets.

chmod 600.env

Most apps load this with the dotenv package. If yours doesn't already, add it.

npm install dotenv

And at the very top of your entry file (often index.js or server.js), before anything else runs:

require('dotenv').config();

One quick test before going further. Run the app directly and see if it starts.

node server.js

If it prints something like Server listening on port 3000 and doesn't crash, you're in good shape. Stop it with Ctrl+C and move on to running it properly.

Step 5: Run the app with pm2

You don't want your app dying the moment you close your SSH session, and you want it to come back if it crashes or the server reboots. That's what pm2 does. It's a process manager that keeps Node apps running.

Install it globally.

npm install -g pm2

Start your app through pm2. Give it a name so you can find it later.

pm2 start server.js --name my-app

Check that it's up.

pm2 status

You'll see a table with your app, its status showing online, and how much memory it's using. To watch the logs live:

pm2 logs my-app

Now two commands that people forget, and then wonder why their app vanished after a reboot. First, save the current process list.

pm2 save

Then generate the startup script that brings pm2 back when the server boots.

pm2 startup

That command prints another command for you to copy and run with sudo. It looks roughly like this, with your actual user and paths filled in.

sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v20.17.0/bin pm2 startup systemd -u deploy --hp /home/deploy

Run the one pm2 gave you, not this exact line. After that, pm2 and your app survive reboots.

Step 6: Put Nginx in front as a reverse proxy

Your app is listening on port 3000, but you don't want visitors typing a port number, and you definitely don't want to expose Node directly. Nginx sits in front, listens on the normal web ports, and forwards requests to your app. It also handles SSL later.

Install it.

sudo apt install -y nginx

Create a config for your site. Replace yourdomain.com with your real domain.

sudo nano /etc/nginx/sites-available/my-app

Paste this in. The key line is proxy_pass, which points at your app. The header lines pass along the real visitor info and let WebSockets work if your app uses them.

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;
    }
}

Enable the site by linking it into sites-enabled, then remove the default site so it doesn't get in the way.

sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default

Always test the config before reloading. This catches typos.

sudo nginx -t

You want to see syntax is ok and test is successful. If you do, reload Nginx.

sudo systemctl reload nginx

Step 7: Point your domain and add SSL

Go to wherever you manage DNS and create an A record for your domain pointing at your server's public IP. Add one for www too, or set it as a CNAME to the root. DNS changes can take a little while to spread, sometimes minutes, sometimes longer. You can check progress with:

dig +short yourdomain.com

When that returns your server's IP, you're ready for SSL. We use Let's Encrypt through Certbot, which is free and renews itself.

sudo apt install -y certbot python3-certbot-nginx

Run Certbot and let it handle Nginx for you.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

It'll ask for an email and whether to redirect HTTP to HTTPS. Say yes to the redirect. Certbot edits your Nginx config, adds the certificate, and reloads. Renewal is automatic through a systemd timer, but you can confirm it works with a dry run.

sudo certbot renew --dry-run

Open your domain in a browser. You should see your app served over HTTPS with a padlock. If you're hosting on a Bytte.cloud VPS, the firewall and DDoS protection are already handled at the network edge, so you only need to worry about ports on the box itself.

Step 8: Deploy updates cleanly

When you change code and want the server running the new version, the routine is short. Pull the changes, reinstall anything new, rebuild if needed, then tell pm2 to reload.

cd ~/your-app
git pull
npm ci
npm run build
pm2 reload my-app

Use pm2 reload rather than restart when you can. Reload tries to swap the process without dropping requests, so visitors don't see an error during the switch. A plain restart kills and respawns, which is fine for most apps but causes a brief blip.

If you changed the .env file, a reload may not pick up the new values depending on how your app reads them. A full restart is the safe move there.

pm2 restart my-app --update-env

Troubleshooting

A few problems come up again and again. Here's how to deal with them.

The app keeps crashing or restarting

If pm2 status shows a climbing restart count, the app is crashing and pm2 is dutifully relaunching it. Read the logs to find out why.

pm2 logs my-app --lines 100

Most of the time it's a missing environment variable, a database it can't reach, or a module that wasn't installed. Run node server.js by hand once. The error you get in the foreground is usually clearer than the log scroll.

Port already in use (EADDRINUSE)

This means something is already sitting on the port your app wants. Often it's an old copy of the app you forgot to stop. Find what's holding the port.

sudo lsof -i:3000

If it's a stray pm2 process, list and clean it up.

pm2 list
pm2 delete old-app

502 Bad Gateway from Nginx

A 502 means Nginx is up but can't talk to your app. Almost always the app isn't running, or it's listening on a different port than the one in your Nginx config. Confirm the app is online with pm2 status, then check that the port in your .env matches the proxy_pass line. Also make sure your app binds to 127.0.0.1 or 0.0.0.0, not some other address.

Environment variables aren't loading

If your app starts but acts like the .env values are missing, check a couple of things. Is require('dotenv').config() at the very top, before the code that uses the variables? Is the .env file in the same directory you start the app from? And if you started pm2 before creating the file, restart with --update-env so it picks up the values.

Certbot can't get a certificate

If Certbot fails, the usual cause is DNS not pointing at the server yet, or port 80 being blocked. Certbot needs to reach your domain over HTTP to prove you own it. Confirm dig +short yourdomain.com shows your IP, and that nothing is blocking port 80 inbound.

Where to go from here

You now have a real production setup. Code pulled from Git, Node managed by nvm, the app kept alive by pm2 and started on boot, Nginx proxying traffic, and HTTPS handled by Let's Encrypt. That's the same pattern that runs serious sites, just scaled to one box. From here it's worth setting up a simple deploy script so updates are one command, and pointing pm2's logs somewhere you'll actually look at them. But the hard part is done, and your app is live.

Common questions

Why use pm2 instead of just running node?

A plain node process dies when you close your SSH session and won't restart if it crashes. pm2 keeps the app running in the background, restarts it automatically on failure, and with pm2 save and pm2 startup it comes back after a server reboot.

Do I need Nginx if my Node app already serves HTTP?

You don't strictly need it, but it's strongly recommended. Nginx lets you serve on the normal ports without exposing Node directly, handle SSL cleanly, pass real visitor headers, and run several apps on one server by domain name.

How do I deploy an update without downtime?

Pull the new code with git pull, run npm ci and any build step, then run pm2 reload my-app. Reload swaps the process while keeping requests flowing, so visitors don't see an error during the switch.

My app starts but the .env values are missing. What's wrong?

Make sure require('dotenv').config() runs at the very top of your entry file before anything reads the variables, that the.env file sits in the directory you start the app from, and if pm2 was already running, restart it with --update-env.

I get a 502 Bad Gateway. How do I fix it?

A 502 means Nginx can't reach your app. Check that the app is online with pm2 status, and that the port in your.env matches the proxy_pass line in the Nginx config. Confirm the app binds to 127.0.0.1 or 0.0.0.0.

DO
Daniel Okafor
Web Developer at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans