DEV Community

Cover image for Deploy FastAPI and a React app on a VPS with nginx, systemd and uv
Aravind Govindhasamy
Aravind Govindhasamy

Posted on Originally published at starter.budeglobal.in on

Deploy FastAPI and a React app on a VPS with nginx, systemd and uv

A single small VPS (or a home server) runs a FastAPI + React app comfortably. No containers needed: the frontend is static files, the API is one Python process under systemd, and nginx sits in front of both.

Browsers reach nginx, which serves the frontend's static files and proxies API requests to uvicorn; uvicorn calls Supabase

What you need

  • An Ubuntu server (22.04 or newer) you can SSH into
  • A domain with two DNS records pointing at the server: example.com (the site) and api.example.com (the API)
  • Your app's code, with the backend managed by uv

1. Prepare the server

sudo apt update && sudo apt install -y nginx
curl -LsSf https://astral.sh/uv/install.sh | sh # installs uv for your user

Enter fullscreen mode Exit fullscreen mode

Open only what you need with the firewall:

sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable

Enter fullscreen mode Exit fullscreen mode

2. Build the frontend where you develop

Build on your own machine (or in CI), not on the server: the server doesn't need Node, and a build failure never takes the site down.

cd frontend
API_URL=https://api.example.com SITE_URL=https://example.com npm run build

Enter fullscreen mode Exit fullscreen mode

Copy the output to the server:

tar -czf - -C dist . | ssh user@server 'sudo mkdir -p /var/www/example && sudo tar -xzf - -C /var/www/example'

Enter fullscreen mode Exit fullscreen mode

(If your build writes to a subfolder, such as dist/client for a pre-rendered TanStack Start site, copy that folder instead.)

3. Install the API

Copy the backend to the server (for example with git archive), then:

cd ~/app/backend
uv sync --frozen --no-dev

Enter fullscreen mode Exit fullscreen mode

Put secrets in ~/app/.env with permissions 600. Then create /etc/systemd/system/example-api.service:

[Unit]
Description=Example API
After=network-online.target

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

[Install]
WantedBy=multi-user.target


sudo systemctl daemon-reload && sudo systemctl enable --now example-api
curl http://127.0.0.1:8000/health

Enter fullscreen mode Exit fullscreen mode

--proxy-headers with --forwarded-allow-ips 127.0.0.1 makes uvicorn trust nginx's X-Forwarded-For, so your logs and rate limits see real client IPs, not 127.0.0.1.

4. Configure nginx

/etc/nginx/sites-available/example:

server {
    server_name example.com;
    root /var/www/example;

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/index.html /index.html;
    }
}

server {
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Enter fullscreen mode Exit fullscreen mode

The try_files line serves a page's own HTML when it exists (pre-rendered pages) and falls back to the single-page app for every other path. Use your SPA's shell file in place of /index.html if it has a different name.

sudo ln -s /etc/nginx/sites-available/example /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Enter fullscreen mode Exit fullscreen mode

5. HTTPS

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

Enter fullscreen mode Exit fullscreen mode

Certbot edits the nginx config and renews certificates automatically. Behind Cloudflare, or with no public IP at all, use Cloudflare Tunnel instead and skip this step.

6. Redeploy

A redeploy is the same three moves: build and copy the frontend, copy the backend and uv sync, restart the service.

sudo systemctl restart example-api && curl -fsS https://api.example.com/health

Enter fullscreen mode Exit fullscreen mode

Checklist

  • .env is readable only by the service user, and never committed.
  • The API listens on 127.0.0.1 only; nginx is the only public entry.
  • An uptime monitor watches /health.
  • Logs: journalctl -u example-api -f.

Using it with FastAPI Supabase Starter

This is how the starter's own demo runs. Build with API_URL and SITE_URL, copy frontend/dist/client, and use /_shell.html as the fallback in try_files. The API needs no database password: it talks to Supabase over HTTPS. Webhook and hook URLs to register afterwards are listed in Deploy to production.

Top comments (0)