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.



