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.
Top comments (0)