Deploy React + Node.js on a VPS with Nginx
Running a React frontend and a Node backend on your own server means one machine you fully control — and one machine you're fully responsible for. This guide is the complete path: creating the server, locking it down, installing Node, PM2 and Nginx, getting the app running behind a domain with HTTPS, and deploying updates afterwards. It starts, though, with the question most VPS tutorials skip: whether you should be doing this at all.
Affiliate disclosure: Some links on this page are affiliate links to hosting providers we cover. If you sign up through them, I may earn a small commission at no extra cost to you. This never affects which hosts are covered or how they're ranked.
Should you actually run a VPS?
Worth answering honestly before you spend an afternoon on it, because for a lot of React apps the answer is no.
A static React site
Put it on a static host. Netlify, Cloudflare Pages and Vercel all do this free, better than a hand-configured server will, with a global CDN you'd otherwise have to build. We earn nothing from any of them — it's just the right answer. Start at best hosting for React apps.
React plus a Node backend, nothing unusual
A managed platform that deploys from Git will do this with far less of your time, and handle TLS, restarts and rollbacks for you. See hosting for full-stack React or the React + Express deploy guide.
A VPS genuinely fits when…
You need particular system packages or runtimes; you want several apps on one machine at one predictable price; you need control over where data physically sits; you want long-running background work without per-instance pricing; or you simply want to own the whole stack and are willing to maintain it.
If you're in that last group, the rest of this guide is for you. The work is not hard — but it is ongoing, and that's the part worth deciding on deliberately.
What you'll need
- A React app that builds to a
dist/folder, and a Node backend that listens on a port - A server running a current Ubuntu LTS release
- A domain you can edit DNS records for
- Comfort with a terminal and SSH
The commands below assume Ubuntu. On Debian they're identical; on other distributions the package manager differs but the shape of the work doesn't.
Step 1: Create the server
Any provider selling a plain Ubuntu VPS works here. DigitalOcean droplets are a common choice for this because pricing is a fixed monthly figure per size and managed databases sit alongside if you later want to stop running Postgres yourself.
When creating it: pick a current Ubuntu LTS image, the smallest size that fits (you can resize later), a region near your users, and — importantly — add your SSH key during creation rather than using a password. Then connect:
ssh root@YOUR_SERVER_IP
apt update && apt upgrade -y Verify current pricing and available sizes on the provider's own page — plans change, and any figure written into a guide is out of date eventually.
Step 2: Create a non-root user
Running your app as root means any flaw in it is a flaw with full control of the machine. Make a normal user and use that from here on:
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy That last line copies your SSH key across so the new user can log in the same way. Open a second terminal and confirm it works before closing the first:
ssh deploy@YOUR_SERVER_IP Keeping the root session open until the new login is proven saves you from locking yourself out of a fresh server — which is a rite of passage you can skip.
Step 3: Turn on the firewall
A server on a public IP is scanned within minutes of existing. Allow only what you need:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status Note that your Node process on port 3001 is deliberately not opened. It should only ever be reachable through Nginx, not directly from the internet.
Step 4: Install Node.js
Install via nvm rather than the distribution's package, so you control the version and can change it without fighting apt:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
source ~/.bashrc
nvm install --lts
node -v Match the major version to what you develop against. A backend built on one major version and run on another is a category of bug you don't need.
Step 5: Install PM2
Running node index.js in an SSH session stops your app the moment you disconnect. PM2 keeps it running, restarts it if it crashes, and starts it again after a reboot:
npm install -g pm2 Step 6: Install Nginx
Nginx sits in front of everything: it serves your React build directly, forwards API requests to Node, and later terminates HTTPS.
sudo apt install nginx -y
sudo systemctl status nginx Visiting the server's IP in a browser should now show the default Nginx page. If it doesn't, the firewall rule from step 3 is the first thing to check.
Step 7: Get your app onto the server
Cloning from Git is the approach that makes later updates trivial:
sudo mkdir -p /var/www
sudo chown deploy:deploy /var/www
cd /var/www
git clone YOUR_REPO_URL myapp
cd myapp
npm install
npm run build Create the environment file the backend reads, and keep it out of Git:
NODE_ENV=production
PORT=3001
DATABASE_URL=...
JWT_SECRET=... Confirm .env is in .gitignore. Committed secrets stay in the history even after you delete them.
Step 8: Start the backend with PM2
cd /var/www/myapp
pm2 start server/index.js --name myapp
pm2 startup
pm2 save
pm2 status pm2 startup prints a command to run — run it, or the app won't come back after a reboot. pm2 save records the current process list as the one to restore.
Check it responds locally before involving Nginx: curl http://localhost:3001/api/health.
Step 9: Configure Nginx
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name YOUR_DOMAIN www.YOUR_DOMAIN;
root /var/www/myapp/client/dist;
index index.html;
location /api/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location / {
try_files $uri $uri/ /index.html;
}
} - Nginx serves the React build directly — faster than proxying static files through Node
try_files … /index.htmlis what makes a hard refresh on a client-side route work instead of 404 — the same problem covered host by host in fixing the React Router 404- The
/api/block goes beforelocation /, or the catch-all claims your API - The
Upgradeheaders are what allow WebSockets through
Enable it and reload:
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 nginx -t checks the config before you apply it. Run it every time — reloading a broken config takes the site down.
Step 10: Point your domain at the server
At your DNS provider, create two records pointing at the server's IP:
A @ YOUR_SERVER_IP
A www YOUR_SERVER_IP Propagation is usually minutes but can take longer. Don't move to the next step until the domain actually resolves to your server — the certificate request depends on it.
Step 11: Enable HTTPS
Certbot obtains a free Let's Encrypt certificate and rewrites your Nginx config to use it:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d YOUR_DOMAIN -d www.YOUR_DOMAIN
sudo certbot renew --dry-run Accept the offer to redirect HTTP to HTTPS. The dry run confirms automatic renewal will work — worth doing now rather than discovering in ninety days that it doesn't.
Deploying updates
Save this as deploy.sh in the project root:
#!/usr/bin/env bash
set -e
cd /var/www/myapp
git pull
npm install
npm run build
pm2 restart myapp set -e matters: without it a failed build is followed cheerfully by a restart, and you've just deployed a broken version. With it the script stops at the first failure and the running app is left alone.
Make it executable with chmod +x deploy.sh. Once it's reliable, a GitHub Action can SSH in and run it on every push to main.
What you've taken on
The app is running, but the machine is now yours to look after: security updates, certificate renewal (automatic, but worth verifying), disk space, backups, and the fact that a crashed server doesn't page anyone. None of it is difficult; it just never stops.
If that ongoing cost is the part you don't want, there are two honest ways out. Cloudways manages servers on this same class of infrastructure — provisioning, patching, backups and monitoring handled for a flat per-server price, which is a common choice for people running client work who don't want to be on call. Or drop the server model entirely and use a platform that deploys from Git; hosting for full-stack React compares those.
Neither is better in the abstract. You're choosing between paying with your time and paying with money, and only you know which you have more of.
Troubleshooting
502 Bad Gateway
Nginx is running but Node isn't reachable. Check pm2 status shows the app online, and that the port in proxy_pass matches the port the app actually listens on.
The default Nginx page still shows
The default site is still enabled and winning. Remove /etc/nginx/sites-enabled/default, then test and reload.
Refreshing a route gives 404
The try_files line is missing or the root points somewhere without an index.html. Confirm the build output really is at the path in root.
API requests return the React app
The location / block is matching first. Put the /api/ block above it.
Certbot fails to issue a certificate
Usually DNS hasn't propagated yet, or port 80 is blocked. Confirm the domain resolves to the server and that ufw status shows Nginx allowed.
The app is gone after a reboot
The pm2 startup command it printed was never actually run, or pm2 save wasn't run afterwards. Do both, then reboot once to prove it.
Build fails on the server but works locally
Usually memory. A small instance can run out during a large Vite build — either add swap, or build locally and deploy the artefact instead of building on the server.
Frequently asked questions
Do I need a VPS to host a React app?
No — and for most React apps you shouldn't. A static React site belongs on a static host, and a React app with a Node backend runs happily on a managed platform that deploys from Git. A VPS is the right answer when you need specific system packages, unusual networking, strict control over where data sits, or several apps sharing one machine. Choose it deliberately, not by default.
What size server do I need?
The smallest tier most providers sell is enough to run Nginx, a Node process and a small database for a low-traffic app. Providers let you resize later, so start small and move up when monitoring tells you to rather than guessing upward at the beginning.
Why put Nginx in front of Node instead of exposing Node directly?
Nginx terminates TLS, serves your static React files far more efficiently than Node will, and lets you run several apps on one machine behind different domains. It also means a crashed or restarting Node process produces a clean error page rather than a dead connection.
How is this different from using a managed platform?
On a VPS you own the operating system: security updates, the firewall, TLS renewal, process supervision and backups are all yours. That's the cost of the control you gain. A managed platform does those for you and takes away some flexibility in exchange — which trade is better depends entirely on what your app needs.
DigitalOcean droplets are a straightforward place to run this setup, with fixed monthly pricing and managed databases alongside if you want them. If you'd rather have someone else own the OS updates and backups, Cloudways manages servers on the same infrastructure.
Written by Andrius Vaitiekūnas
Developer who deploys and hosts React apps. I write the deployment guides I wish existed — tested on real projects, hard parts included.
Related guides
Best hosting for full-stack React apps (2026)
A React app with a backend and database needs always-on hosting that serverless platforms can't provide. An even-handed comparison of Railway, Render, DigitalOcean, Cloudways, and Kinsta — by traffic shape and pricing model, not hype.
How to fix React Router 404 on refresh (Netlify, Vercel, Cloudflare & traditional servers)
Your React app works until someone refreshes the page — then the server returns a 404. Here is why it happens and the exact fix for Netlify, Cloudflare Pages, Vercel, Apache/LiteSpeed shared hosting, and Nginx.