Skip to content
ohsudo, Home
Networking

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.

Intermediate3 min readApplies to Nginx 1.18+ · FastAPI · Uvicorn · Gunicorn · Python 3.10+ · Ubuntu · Debian

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.

Terminal window
cd /var/www/myapi
python3 -m venv .venv
.venv/bin/pip install "fastapi[standard]"

Save this as /etc/systemd/system/myapi.service:

[Unit]
Description=FastAPI app
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/myapi
ExecStart=/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.1
Restart=on-failure
[Install]
WantedBy=multi-user.target
Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now myapi
curl -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

Terminal window
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 -t
sudo systemctl reload nginx
curl -i https://api.example.com/docs

If 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=myapi
ExecStart=/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.

Tools

Try it yourself

Articles

Related articles