Master FastAPI endpoint URLs behind a NGINX reverse proxy

The default that breaks in production

Geschrieben von Timo Rieber am 29. Januar 2024

After deploying a new version of cloudapps, a course-management platform, some API requests started failing in production. The backend is a Python app based on FastAPI and Starlette, served by Uvicorn. The frontend is a Vue.js SPA. Both run as Docker containers behind a NGINX reverse proxy that terminates TLS. In the local development environment, there is no reverse proxy.

I had restructured some endpoint URLs and adapted the frontend accordingly. Backend tests passed, frontend integration tests against the local backend passed. Then in production the frontend couldn't load certain data. The requests came back as HTTP 307 temporary redirects to the same URL, but with a trailing slash and, more importantly, the scheme changed from https to http:

* Original request: https://timorieber.de/api/health * Redirected to: http://timorieber.de/api/health/

The browser blocked these redirects due to mixed content. HTTPS to HTTP is not allowed. The obvious suspect was the NGINX reverse proxy, the one component missing from local development.

The culprit

After some digging I found the real source: FastAPI itself. By default it redirects requests without a trailing slash to the same URL with a trailing slash, if the route exists.

from fastapi import FastAPI, APIRouter

healthcheck_router = APIRouter()

@healthcheck_router.get('/')
async def healthcheck() -> dict:
    return {'state': 'healthy'}

def create_app() -> FastAPI:
    app = FastAPI()
    app.include_router(healthcheck_router, prefix='/api/health')
    return app
Python

This defines /api/health/ (with trailing slash). A request to /api/health (without it) gets automatically redirected to /api/health/. The backend tests hit /api/health and got 200 because the test client follows the redirect transparently. Same for manual frontend testing. Without a reverse proxy in the middle, nobody noticed.

The redirect constructs the target URL from the incoming request. Behind NGINX the original scheme information is lost, so the redirect points to http instead of https.

Option 1: forward the original scheme through NGINX

User pjaydev on GitHub has provided a great explanation of this approach.

The fix is to forward the original request headers so the redirect URL is constructed correctly:

location / {
    [...possible other configuration...]

    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://backend;
}
Nginx

Uvicorn also needs to process these headers:

uvicorn --proxy-headers --forwarded-allow-ips=*
Bash

It worked for me without --proxy-headers, but being explicit is better. From a security perspective, restrict --forwarded-allow-ips=* to the actual IP of the reverse proxy.

I don't like this solution:

* It adds complexity to the production setup. * It requires knowledge about the "how" and "why". * It hides the magic redirect from the backend stack. * It does an extra roundtrip for the redirect. * Most importantly: it is not easily testable.

Option 2: disable the redirect in FastAPI

I prefer to tackle problems at their root. FastAPI lets you disable the redirect by setting redirect_slashes to False (app-level, router-level). You only need to configure one of them, depending on your needs:

from fastapi import FastAPI, APIRouter

healthcheck_router = APIRouter(redirect_slashes=False)

@healthcheck_router.get('/')
async def healthcheck() -> dict:
    return {'state': 'healthy'}

def create_app() -> FastAPI:
    app = FastAPI(redirect_slashes=False)
    app.include_router(healthcheck_router, prefix='/api/health')
    return app
Python

This is straightforward to test:

def test_healthcheck_returns_200(test_client):
    response = test_client.get('/api/health/')
    assert response.status_code == 200

def test_healthcheck_without_trailing_slash_returns_404(test_client):
    response = test_client.get('/api/health')
    assert response.status_code == 404
Python

No NGINX coordination, no proxy header forwarding, no hidden redirects. The endpoint either exists or it doesn't. Explicit is better than implicit.