Deploying a React + FastAPI App on a VPS With Nginx (Step by Step)

A step-by-step guide to deploying a React (Vite) frontend and FastAPI backend on a Linux VPS — server hardening, uv/Python setup, systemd, Nginx reverse proxy, SPA routing, HTTPS, CI deploys and backups.

By Danyal Ahmed - Data Scientist · · 3 min read

Not every product needs Kubernetes or a complex cloud setup. A single, well-configured virtual private server (VPS) can reliably host a React frontend and a FastAPI backend for a long time, cheaply and with full control. This guide walks through a production-ready setup: server hardening, the Python backend as a systemd service, Nginx serving the frontend and proxying the API, HTTPS, automated deploys and backups.

It's the same pattern we use for small-to-medium projects: simple, observable and easy to evolve later (choosing a tech stack).

Architecture

textBrowser ──HTTPS──▶ Nginx (TLS, static files, gzip, caching)
                     ├── /           → React build (dist/) with SPA fallback
                     └── /api/*      → FastAPI (uvicorn) on 127.0.0.1:8000 via systemd
                                          └── Database (SQLite for small apps / PostgreSQL)

Step 1: Provision and harden the server

Start with a recent Ubuntu LTS VPS, then:

bash# Create a non-root user with sudo
adduser deploy && usermod -aG sudo deploy

# SSH keys only; disable password and root login (edit /etc/ssh/sshd_config)
#   PasswordAuthentication no
#   PermitRootLogin no
sudo systemctl restart ssh

# Firewall: allow SSH and web traffic only
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

# Automatic security updates
sudo apt update && sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Consider fail2ban for SSH brute-force protection. The backend will listen only on 127.0.0.1, so it's never exposed directly.

Step 2: Install dependencies

bashsudo apt install -y nginx git curl
# Node.js (LTS) for building the frontend, e.g. via NodeSource or a version manager
# uv for fast, reproducible Python environments:
curl -LsSf https://astral.sh/uv/install.sh | sh

Step 3: Set up the FastAPI backend

bashsudo mkdir -p /srv/app && sudo chown deploy:deploy /srv/app
git clone git@github.com:acme/app.git /srv/app
cd /srv/app/backend
uv sync --frozen        # creates .venv from the lockfile

Store configuration in an environment file readable only by the service user:

bash# /srv/app/backend/.env   (chmod 600)
SECRET_KEY=change-me-to-a-long-random-value
DATABASE_URL=postgresql://app:***@localhost:5432/app
ALLOWED_ORIGINS=https://example.com

Never commit this file (AI guardrails and safety and CI/CD guide cover secret handling).

Run it with systemd

ini# /etc/systemd/system/app-backend.service
[Unit]
Description=App backend (FastAPI)
After=network.target

[Service]
User=deploy
WorkingDirectory=/srv/app/backend
EnvironmentFile=/srv/app/backend/.env
ExecStart=/srv/app/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2 --proxy-headers
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
bashsudo systemctl daemon-reload
sudo systemctl enable --now app-backend
sudo systemctl status app-backend
journalctl -u app-backend -f        # logs

systemd restarts the process if it crashes and starts it on boot. Size --workers to CPU cores and memory; with a database, remember the connection pool per worker (connection pooling explained).

Step 4: Build the React frontend

bashcd /srv/app/frontend
npm ci
npm run build           # outputs static files to dist/

The build output is plain static files: HTML, JavaScript, CSS and images. Nginx serves them directly, which is extremely efficient.

Step 5: Configure Nginx

nginx# /etc/nginx/sites-available/app
server {
    listen 80;
    server_name example.com www.example.com;

    root /srv/app/frontend/dist;
    index index.html;

    # Hashed build assets: cache for a long time
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # API → FastAPI
    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        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_read_timeout 60s;
        client_max_body_size 20m;
    }

    # SPA: serve the file or prerendered page if it exists, otherwise index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
}
bashsudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

SPA routing and SEO

try_files $uri $uri/ /index.html makes client-side routes like /dashboard work on refresh. If you prerender content pages at build time (for example, writing dist/blog/my-post/index.html), the same rule serves that static HTML to users and search engines, so crawlers get full content and meta tags without executing JavaScript (Core Web Vitals guide).

Step 6: HTTPS with Let's Encrypt

bashsudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot obtains certificates, updates the Nginx config for HTTPS and installs automatic renewal. Verify renewal with sudo certbot renew --dry-run. Consider adding HSTS once HTTPS is solid.

Step 7: Automated deploys

A simple deploy script:

bash#!/usr/bin/env bash
set -euo pipefail
cd /srv/app
git fetch origin main && git reset --hard origin/main

cd backend && uv sync --frozen
# run database migrations here (e.g. alembic upgrade head)
sudo systemctl restart app-backend

cd ../frontend && npm ci && npm run build
sudo systemctl reload nginx
echo "Deployed $(git rev-parse --short HEAD)"

Trigger it from CI on merges to main (for example, via SSH from a GitHub Actions job using a deploy key with limited permissions). Run tests before deploying (CI/CD pipeline guide).

Reducing downtime: restarting a single backend process causes a brief interruption. Options: run two backend instances behind an Nginx upstream and restart them one at a time; build the frontend into a new directory and switch a symlink atomically; and use backward-compatible database migrations (zero-downtime deployments).

Step 8: Backups and monitoring

Backups

  • Automated database dumps (daily at minimum), copied off the server to object storage, with retention.
  • VPS provider snapshots as an extra layer.
  • Test restores periodically. An untested backup isn't a backup.

Monitoring

  • Uptime checks on the homepage and an API health endpoint.
  • Error tracking for frontend and backend exceptions.
  • Server metrics: CPU, memory, disk (disk-full is a classic outage cause), plus log rotation.
  • Structured logs from the backend (observability explained).

Step 9: Security checklist

  • SSH keys only; root login disabled; firewall enabled
  • Automatic security updates
  • Backend bound to 127.0.0.1 only
  • Secrets in .env with restricted permissions, never in git
  • HTTPS with auto-renewal; HTTP redirects to HTTPS
  • CORS restricted to your domains
  • Rate limiting on auth endpoints (rate limiting algorithms)
  • Admin routes protected and ideally not indexed (Disallow: /admin in robots.txt)
  • Off-server backups tested

When to outgrow a single VPS

Signs it's time to evolve:

  • CPU or memory regularly saturated even after optimization (how to scale a web application).
  • You need high availability (no single point of failure).
  • Deploys need zero downtime at higher traffic.
  • The team needs preview environments and more automation.

Next steps: move the database to a managed service, add a second app server behind a load balancer, containerize the app (Docker explained), and move to a PaaS or container platform (AWS vs GCP vs Azure).

FAQ

Is a VPS good enough for production?

For many small and medium applications, yes, if it's hardened, monitored, backed up and deployed via automation. The main limitation is that a single server is a single point of failure, which you can address later with redundancy.

Should I use Docker on the VPS?

Docker (with Compose) makes environments reproducible and simplifies running dependencies. Running directly with systemd and a Python virtual environment is also perfectly fine and has fewer moving parts. Both are valid choices.

Why use Nginx in front of FastAPI?

Nginx efficiently serves static frontend files, terminates TLS, handles compression and caching headers, buffers slow clients and proxies API requests to uvicorn. Uvicorn then focuses on running your application.

Why do my React routes 404 on refresh?

The server looks for a file matching the path, like /dashboard, and doesn't find one. Adding the try_files $uri $uri/ /index.html; fallback serves the SPA entry point so the client-side router can handle the route.