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.
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) andapi.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 Open only what you need with the firewall:
sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable 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 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' (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 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 --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; } } 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 5. HTTPS
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d example.com -d api.example.com 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 Checklist
-
.envis readable only by the service user, and never committed. - The API listens on
127.0.0.1only; 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.

