Your Node.js app works perfectly on your laptop. You type node server.js, open localhost:3000, and everything looks great. Then you close the terminal and the whole thing dies.
That’s the gap between “it works on my machine” and a real, live deployment. A VPS fills that gap, but only if you set it up properly. Running node server.js over SSH and leaving the window open is not a deployment. It’s a countdown.
In this guide, we’ll do it the right way on a fresh Ubuntu VPS. PM2 keeps your app running 24/7 and restarts it if it crashes. Nginx sits in front of it and handles traffic on ports 80 and 443. A free Let’s Encrypt SSL certificate gives you HTTPS. By the end, your app will be live on your own domain and will survive crashes and server reboots.
What You Need Before You Start
- A VPS running Ubuntu 22.04 or 24.04. Any Linux VPS with at least 1 GB RAM will do. If you don’t have one yet, you can get a Linux VPS here or try a free VPS trial first.
- SSH access to the server (root or a sudo user).
- A domain name (optional for testing, required for SSL).
- A Node.js app. Don’t have one? We’ll create a small Express app in Step 4 so you can follow along.
If you’re brand new to a VPS, read our guide on the first things to do after launching your Linux VPS before you continue.
Step 1: Connect to Your VPS and Update It
Open a terminal and connect over SSH. Replace the IP with your server’s IP address:
ssh root@YOUR_SERVER_IP
Then update the package list and upgrade what’s installed:
apt update
apt upgrade -y
This takes a minute or two. Don’t skip it. Old packages are the easiest way to end up with security problems later.
Step 2: Create a Non-Root User
Running your app as root is a bad idea. If someone finds a bug in your app, they get the keys to your whole server. Create a normal user instead:
adduser deploy
usermod -aG sudo deploy
If you log in with an SSH key, copy it to the new user so you can connect as deploy directly:
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Now switch to the new user:
su - deploy
From here on, we’ll run everything as deploy, using sudo where needed.
Step 3: Install Node.js
The version of Node.js in Ubuntu’s default repository is often old. Use the NodeSource repository to get the current LTS (Long Term Support) release:
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
Check that it worked:
node -v
npm -v
You should see version numbers for both. If you prefer to switch Node versions per project, you can use nvm instead. For a single production app, the NodeSource install is simpler and easier to manage.
Step 4: Upload or Create Your App
You have two options here.
Option A: Clone your app from Git
sudo apt install -y git
cd ~
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git myapp
cd myapp
npm install --omit=dev
Option B: Create a quick test app
Just want to see the whole setup work first? Create a tiny Express app:
mkdir ~/myapp
cd ~/myapp
npm init -y
npm install express
nano server.js
Paste this into server.js:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/", (req, res) => {
res.send("Hello from my VPS!");
});
app.listen(PORT, "127.0.0.1", () => {
console.log(`Server running on port ${PORT}`);
});
Notice the 127.0.0.1. That tells the app to listen only on the server itself, so nobody can reach it directly on port 3000. All outside traffic will go through Nginx, which is exactly what we want.
Quick test:
node server.js
Open a second SSH window and run curl http://127.0.0.1:3000. You should see “Hello from my VPS!”. Go back to the first window and press Ctrl + C to stop the app. PM2 will take over from here.
Step 5: Run Your App with PM2
PM2 is a process manager for Node.js. It runs your app in the background, restarts it when it crashes, and brings it back automatically after a reboot. Install it globally:
sudo npm install -g pm2
Start your app:
cd ~/myapp
pm2 start server.js --name myapp
Check that it’s running:
pm2 status
You should see myapp with the status online.
Make PM2 start on reboot
This is the step most people forget, and then wonder why their site is down after a server restart. Run:
pm2 startup
PM2 prints a command that starts with sudo env PATH=.... Copy that exact command and run it. Then save your current process list:
pm2 save
Use an ecosystem file (recommended)
For anything beyond a test app, keep your settings in a config file instead of typing flags every time. Create ecosystem.config.js in your app folder:
module.exports = {
apps: [
{
name: "myapp",
script: "server.js",
instances: "max",
exec_mode: "cluster",
max_memory_restart: "300M",
env: {
NODE_ENV: "production",
PORT: 3000
}
}
]
};
Here’s what the important lines do:
instances: "max"withexec_mode: "cluster"runs one copy of your app per CPU core, so you actually use the cores you’re paying for.max_memory_restartrestarts the app if it leaks memory past 300 MB. This is a lifesaver on small VPS plans.NODE_ENV: "production"makes Express and many other packages run faster.
Delete the old process and start it from the file:
pm2 delete myapp
pm2 start ecosystem.config.js
pm2 save
PM2 commands you’ll use all the time
| Command | What it does |
|---|---|
pm2 status |
Shows all running apps |
pm2 logs myapp |
Shows live logs for your app |
pm2 restart myapp |
Restarts the app (short downtime) |
pm2 reload myapp |
Zero-downtime restart in cluster mode |
pm2 stop myapp |
Stops the app |
pm2 monit |
Live CPU and memory dashboard |
Running on a 1 GB VPS? Memory gets tight fast. Add some swap space so a traffic spike doesn’t kill your app. We explain how in what swap memory is and whether you should enable it on your VPS.
Step 6: Install and Configure Nginx
Your app is now running on port 3000, but only on the server itself. Nginx will be the front door. It takes requests from the internet on port 80 (and 443 later) and passes them to your app. This is called a reverse proxy.
Why not just expose port 3000? Because Nginx gives you SSL, compression, caching, and protection from slow or malformed requests, and it lets you run several apps on one server. If you want the bigger picture, we covered it in how to set up an Nginx reverse proxy on a VPS.
Install Nginx:
sudo apt install -y nginx
Create a config file for your site. Replace example.com with your real domain:
sudo nano /etc/nginx/sites-available/myapp
Paste this in:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.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;
}
}
The Upgrade and Connection lines are there so WebSockets work. If your app uses Socket.IO or any real-time feature, you need them. If it doesn’t, they do no harm.
Enable the site, remove the default one, test the config, and reload Nginx:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
If nginx -t says syntax is ok and test is successful, you’re good. If it shows an error, read the line number it gives you. It’s usually a missing semicolon.
Step 7: Set Up the Firewall
Only open the ports you actually need: SSH, HTTP, and HTTPS. Allow SSH first, or you’ll lock yourself out.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
Notice that we did not open port 3000. Your app is only reachable through Nginx, and that’s the whole point. For more on this, see our guide on opening ports 80 and 443 with UFW and our full walkthrough on how to secure and firewall a VPS server.
Step 8: Point Your Domain and Add Free SSL
Point your domain to the VPS
Go to your domain registrar’s DNS settings and create two A records:
| Type | Name | Value |
|---|---|---|
| A | @ | YOUR_SERVER_IP |
| A | www | YOUR_SERVER_IP |
DNS can take anywhere from a few minutes to a few hours to update. You can check from your own computer with ping example.com. When it shows your server’s IP, you’re ready. Now open http://example.com in your browser and you should see your app.
Install a free SSL certificate
Browsers now flag sites without HTTPS as “Not secure”, so this step isn’t optional. Install Certbot and let it set everything up:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot will ask for your email address and whether to redirect HTTP to HTTPS. Choose the redirect option. It edits your Nginx config for you.
Let’s Encrypt certificates last 90 days, but Certbot renews them automatically. Confirm the renewal works:
sudo certbot renew --dry-run
Using Cloudflare in front of your server? Install the certificate first, then set Cloudflare’s SSL mode to Full (strict). Our guide on using Cloudflare with your VPS walks through it.
Open https://example.com. You should see the padlock and your app. You’re live.
Step 9: Deploy Updates Without Downtime
Your app is live, but you’ll want to push changes. With a Git-based setup, an update takes four lines:
cd ~/myapp
git pull
npm install --omit=dev
pm2 reload myapp
pm2 reload restarts your app one instance at a time in cluster mode, so visitors don’t see an outage. If you only run a single instance, use pm2 restart myapp instead. You’ll get a brief blip of a second or two.
Once you’re comfortable with this, put those lines in a small shell script (deploy.sh) so every deployment is one command.
Common Errors and How to Fix Them
| Problem | Likely cause | Fix |
|---|---|---|
| 502 Bad Gateway | Your app isn’t running, or Nginx points to the wrong port | Run pm2 status. Make sure proxy_pass uses the same port your app listens on. Check pm2 logs myapp for crashes. |
| EADDRINUSE: address already in use | Another process is using the port | Run sudo lsof -i :3000 to find it, then stop it or change your app’s port. |
| App is gone after a reboot | You skipped pm2 startup or pm2 save |
Run pm2 startup, run the command it prints, then pm2 save. |
| Nginx shows the default welcome page | The default site is still enabled, or server_name doesn’t match |
Remove /etc/nginx/sites-enabled/default and check your domain spelling. |
| Certbot fails to issue a certificate | DNS hasn’t updated yet, or port 80 is blocked | Wait for DNS to propagate and check sudo ufw status. |
| Environment variables missing | PM2 doesn’t load your shell’s variables | Put them in the env block of ecosystem.config.js, then run pm2 reload ecosystem.config.js --update-env. |
When something breaks, check the logs first. They usually tell you exactly what’s wrong:
pm2 logs myapp
sudo tail -f /var/log/nginx/error.log
FAQ
Do I really need Nginx if PM2 already runs my app?
Technically, no. You could run your app on port 80 directly. But you’d lose easy SSL, you couldn’t host multiple apps or sites on one server, and Node.js would handle slow connections and static files less efficiently. Nginx in front is the standard setup for a good reason.
Can I run more than one Node.js app on the same VPS?
Yes. Run each app on its own port (3000, 3001, 3002) with its own PM2 process, then create one Nginx server block per domain, each pointing to a different port.
PM2 or Docker: which one should I use?
PM2 is simpler and uses less memory, which makes it a good fit for small VPS plans and single apps. Docker is better when you have several services, need identical environments, or want easy rollbacks. If you want to try it, see our guide on installing Docker on Ubuntu.
How much RAM does a Node.js app need on a VPS?
A small Express or Next.js app usually runs fine on 1 GB, especially with swap enabled. Heavier apps, builds, or databases on the same server need 2 GB or more.
Can I deploy a Next.js, NestJS or React app the same way?
Yes. For Next.js and NestJS, build the app first (npm run build) and start it with PM2 using npm as the script, for example pm2 start npm --name myapp -- start. A plain React app produces static files, so you can serve those directly from Nginx without PM2.
Final Thoughts
That’s the full setup: a non-root user, Node.js, PM2 keeping your app alive, Nginx handling traffic, a locked-down firewall, and free HTTPS. It sounds like a lot, but once you’ve done it once, a fresh deployment takes about 15 minutes.
The most important parts are pm2 startup plus pm2 save (so your app survives reboots) and keeping port 3000 closed to the outside world. Get those two right and you’ve avoided most of the problems beginners run into.
Don’t have a server to practice on yet? Spin up a free VPS trial and follow this guide from top to bottom. If you get stuck on any step, leave a comment below and we’ll help you out.
