Deploying a React App and a Node.js API on a VPS with Nginx
Md Asaduzzaman · · 5 min read
This post continues from Setting Up a Fresh Ubuntu VPS the Right Way. We have a secure Ubuntu server with a firewall and a domain pointing at it. Now we will:
- Install Node.js
- Build and serve a React app with Nginx
- Run a Node.js / Express API with PM2
- Add HTTPS with Let's Encrypt
- Make updates a one-command job
How the pieces fit
Browser ──HTTPS──▶ Nginx (:443)
├── / → static React files in /var/www/myapp
└── /api/ → Node.js API on 127.0.0.1:5000 (PM2)
Nginx is very fast at serving static files and handling HTTPS, so Node.js only does what it is good at: running your API.
1. Install Node.js with nvm
The Node.js in Ubuntu's own package list is usually old. nvm (Node Version Manager) installs any version for your user, without sudo:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install --lts
node -v
npm -v
Prefer a system-wide install? The NodeSource packages (
deb.nodesource.com) work well too. Just pick one method and stick to it.
2. Get your code onto the server
sudo mkdir -p /var/www/myapp
sudo chown -R $USER:$USER /var/www/myapp
cd ~
git clone https://github.com/your-name/myapp.git
cd myapp
For a private repository, create an SSH key on the server (ssh-keygen -t ed25519) and add the public key as a Deploy key in the GitHub repo settings.
3. Build the React app
Whether you use Vite or Create React App, the build gives you plain HTML, CSS and JS files:
cd ~/myapp/client
npm ci
npm run build # Vite → dist/, Create React App → build/
rsync -a --delete dist/ /var/www/myapp/
npm ci installs exactly what package-lock.json says, which makes builds repeatable. Remember that environment variables like VITE_API_URL are baked in at build time, so set them before npm run build.
4. Install and configure Nginx
sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/myapp
server {
listen 80;
server_name example.com www.example.com;
root /var/www/myapp;
index index.html;
# React Router: send unknown paths to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed assets for a long time
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Forward API calls to the Node.js app
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
The try_files line is the one people forget. Without it, refreshing a page like /about gives a 404, because that file doesn't exist on disk; React Router handles it in the browser.
Enable the site 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
Open http://example.com and your React app should load.
5. Run the Node.js API with PM2
If you start the API with node server.js, it stops when you close SSH or when it crashes. PM2 keeps it running and restarts it after a reboot.
Make sure your server listens on localhost only, so it is reachable through Nginx and not directly:
// server.js
import express from 'express';
const app = express();
app.use(express.json());
app.get('/api/health', (req, res) => res.json({ ok: true }));
const port = process.env.PORT || 5000;
app.listen(port, '127.0.0.1', () => console.log('API listening on ' + port));
Keep secrets in a .env file on the server (never commit it), then start the app:
npm install -g pm2
cd ~/myapp/server
npm ci --omit=dev
pm2 start server.js --name myapp-api
pm2 save
pm2 startup # prints a sudo command, copy and run it
Useful PM2 commands:
| Command | What it does |
|---|---|
pm2 ls | List running apps |
pm2 logs myapp-api | Follow the logs |
pm2 restart myapp-api | Restart after a deploy |
pm2 monit | Live CPU and memory view |
Check it through Nginx: curl http://example.com/api/health should return {"ok":true}.
6. Free HTTPS with Let's Encrypt
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot gets the certificate, edits your Nginx config and sets up a redirect from HTTP to HTTPS. The certificate renews automatically; test the renewal with:
sudo certbot renew --dry-run
7. A simple deploy script
Put this in ~/myapp/deploy.sh so each update is one command:
#!/usr/bin/env bash
set -euo pipefail
cd ~/myapp
git pull --ff-only
# Frontend
cd client
npm ci
npm run build
rsync -a --delete dist/ /var/www/myapp/
# Backend
cd ../server
npm ci --omit=dev
pm2 restart myapp-api
echo "Deployed $(git rev-parse --short HEAD)"
chmod +x ~/myapp/deploy.sh
~/myapp/deploy.sh
Later you can run the same script from GitHub Actions over SSH to deploy on every push to main.
Troubleshooting
- 502 Bad Gateway: Nginx can't reach the API. Check
pm2 lsandpm2 logs, and that the port in Nginx matches the app. - 404 on page refresh: the
try_files ... /index.htmlline is missing. - Build killed on a small VPS: add swap (see the VPS post) or build on your computer / in CI and upload the
distfolder. - Changes don't show: you rebuilt but didn't copy the new files, or the browser cached the old
index.html. Hard-refresh with Ctrl+Shift+R. - Nginx errors:
sudo nginx -tandsudo tail -f /var/log/nginx/error.log.
This setup is cheap, fast and easy to understand. In the next post we package the same app with Docker so the server only needs Docker, not Node.js.