Nginx reverse proxy for FastAPI and Uvicorn, with HTTPS and WebSockets
Run FastAPI behind Nginx: a complete HTTPS config, Uvicorn in a systemd service, proxy headers for the real client IP, a subpath, WebSockets, and common errors.
by OhSudo ·
Uvicorn runs FastAPI, and it isn’t meant to face the internet by itself. Nginx in front handles HTTPS and slow clients, while your app stays on a private port.
The Nginx Config Generator with these options writes this for your domain.
The config
map $http_upgrade $connection_upgrade { default upgrade; '' close;}
server { listen 80; listen [::]:80; server_name api.example.com; return 301 https://api.example.com$request_uri;}
server { listen 443 ssl; listen [::]:443 ssl; server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10m;
location / { proxy_pass http://127.0.0.1:8000; 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;
# WebSockets proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; }}1. Run Uvicorn as a service
Install it in a virtual environment. On current Debian and Ubuntu, pip refuses to install globally: see externally-managed-environment.
cd /var/www/myapipython3 -m venv .venv.venv/bin/pip install "fastapi[standard]"Save this as /etc/systemd/system/myapi.service:
[Unit]Description=FastAPI appAfter=network.target
[Service]User=www-dataWorkingDirectory=/var/www/myapiExecStart=/var/www/myapi/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2 --proxy-headers --forwarded-allow-ips=127.0.0.1Restart=on-failure
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now myapicurl -i http://127.0.0.1:8000/docs--host 127.0.0.1 keeps it off the internet. Don’t use --reload in production: it’s for development, and it doesn’t work with --workers. The systemd Unit Generator can write a fuller unit, with security settings.
2. Get the real client address and scheme
--proxy-headers tells Uvicorn to read X-Forwarded-For and X-Forwarded-Proto, and --forwarded-allow-ips says whom to believe. Only Nginx talks to it, so that’s 127.0.0.1. Then:
@app.get("/whoami")def whoami(request: Request): return {"ip": request.client.host, "scheme": request.url.scheme}should show the visitor’s address and https. Without this, url_for() builds http:// links, which browsers block on an HTTPS page, and /docs can fail to load its schema.
Don’t use --forwarded-allow-ips='*' unless Uvicorn truly can’t be reached any other way. Anyone reaching it directly could then pretend to be any address.
3. Get HTTPS, and turn it on
sudo certbot certonly --standalone -d api.example.com \ --pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/sudo nginx -tsudo systemctl reload nginxcurl -i https://api.example.com/docsIf it fails, see Certbot failed to authenticate.
4. Under a subpath, like /api
Next to another site on the same domain, proxy just that path. The trailing / on proxy_pass strips /api, so your app sees /items:
location /api/ { 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;}Then tell FastAPI, or /docs will look for its schema in the wrong place:
app = FastAPI(root_path="/api")Or use --root-path /api on the Uvicorn command.
5. A Unix socket instead of a port
It’s slightly faster, and nothing can reach it over the network:
RuntimeDirectory=myapiExecStart=/var/www/myapi/.venv/bin/uvicorn main:app --uds /run/myapi/uvicorn.sock --proxy-headers --forwarded-allow-ips=*And in the Nginx config:
proxy_pass http://unix:/run/myapi/uvicorn.sock;Here * is fine, since a socket has no address to check, and only local users can reach it. If you get a 502 with connect() to unix:... failed (13: Permission denied), then Nginx’s user can’t reach the socket. Running the service as www-data solves that.
6. Long requests and streaming
The default wait is 60 seconds, and 504 Gateway Time-out is what you see past that. Raise it only for the path that needs it. For an LLM response or Server-Sent Events, turn off buffering too:
location /stream/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 1h;}Otherwise, Nginx holds on to the output, and it all arrives at the end.
When it goes wrong
| You see | Means | Go to |
|---|---|---|
502 Bad Gateway |
Uvicorn isn’t running, or is on another port | Nginx 502 |
504 Gateway Time-out |
A request took over 60 seconds | Nginx 504 |
413 on an upload |
The file is over client_max_body_size |
Nginx 413 |
/docs loads, but says Failed to load API definition |
A subpath without root_path |
Section 4 |
Redirects go to http:// |
Missing --proxy-headers |
Section 2 |
The WebSocket fails with 403 or 400 |
Missing Upgrade headers, or an origin check in your code |
The config above |
Warning
Turn off the docs in production if the API isn’t public: FastAPI(docs_url=None, redoc_url=None, openapi_url=None). And never expose port 8000 directly, as ufw may not protect it if the app runs in Docker: bind to 127.0.0.1.
Found this useful? Share it.
Found a mistake? Let us know at contact@ohsudo.com.