← Back to blog

Deploying a React App and a Node.js API on a VPS with Nginx

Md Asaduzzaman · · 5 min read

DevOpsNode.jsReactNginxDeployment

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:

  1. Install Node.js
  2. Build and serve a React app with Nginx
  3. Run a Node.js / Express API with PM2
  4. Add HTTPS with Let's Encrypt
  5. Make updates a one-command job

How the pieces fit

code
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:

bash
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

bash
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:

bash
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

bash
sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/myapp
nginx
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:

bash
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:

js
// 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:

bash
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:

CommandWhat it does
pm2 lsList running apps
pm2 logs myapp-apiFollow the logs
pm2 restart myapp-apiRestart after a deploy
pm2 monitLive 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

bash
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:

bash
sudo certbot renew --dry-run

7. A simple deploy script

Put this in ~/myapp/deploy.sh so each update is one command:

bash
#!/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)"
bash
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 ls and pm2 logs, and that the port in Nginx matches the app.
  • 404 on page refresh: the try_files ... /index.html line is missing.
  • Build killed on a small VPS: add swap (see the VPS post) or build on your computer / in CI and upload the dist folder.
  • 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 -t and sudo 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.

© ALL RIGHT RESERVED ASADUZZAMAN