From f23fba1eade3d1aca643dd59bcd152a51e2818f2 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 27 Oct 2025 22:35:56 +0100 Subject: [PATCH] Update the part of "Behind a proxy" section related to `root_path` --- docs/en/docs/advanced/behind-a-proxy.md | 371 +++++++++++++++--- docs_src/behind_a_proxy/tutorial005.py | 27 ++ .../test_behind_a_proxy/test_tutorial005.py | 58 +++ 3 files changed, 404 insertions(+), 52 deletions(-) create mode 100644 docs_src/behind_a_proxy/tutorial005.py create mode 100644 tests/test_tutorial/test_behind_a_proxy/test_tutorial005.py diff --git a/docs/en/docs/advanced/behind-a-proxy.md b/docs/en/docs/advanced/behind-a-proxy.md index 4d19d29e0..d51c532b1 100644 --- a/docs/en/docs/advanced/behind-a-proxy.md +++ b/docs/en/docs/advanced/behind-a-proxy.md @@ -97,19 +97,52 @@ These headers preserve information about the original request that would otherwi When **FastAPI CLI** is configured with `--forwarded-allow-ips`, it trusts these headers and uses them, for example to generate the correct URLs in redirects. -## Proxy with a stripped path prefix { #proxy-with-a-stripped-path-prefix } +## Serve the app under a path prefix -You could have a proxy that adds a path prefix to your application. +In production, you might want to serve your FastAPI application under a URL prefix, such as: -In these cases you can use `root_path` to configure your application. +``` +https://example.com/api/v1 +``` -The `root_path` is a mechanism provided by the ASGI specification (that FastAPI is built on, through Starlette). +instead of serving it directly at the root of the domain. -The `root_path` is used to handle these specific cases. +### Why serve under a prefix? -And it's also used internally when mounting sub-applications. +This is common when: -Having a proxy with a stripped path prefix, in this case, means that you could declare a path at `/app` in your code, but then, you add a layer on top (the proxy) that would put your **FastAPI** application under a path like `/api/v1`. +* You host several applications on the same host and port - for example, an API, a dashboard, and an admin panel, all behind the same domain. +Serving them under different prefixes (like `/api`, `/dashboard`, and `/admin`) helps you avoid setting up multiple domains or ports and prevents cross-origin (CORS) issues. +* You deploy multiple API versions (e.g. `/v1`, `/v2`) side by side to ensure backward compatibility while rolling out new features. + +Hosting under a prefix keeps everything accessible under a single base URL (like `https://example.com`), simplifying proxy, SSL, and frontend configuration. + +### Routing requests through a reverse proxy + +In these setups, you typically run your FastAPI app behind a reverse proxy such as Traefik, Nginx, Caddy, or an API Gateway. +The proxy is configured to route requests under a specific path prefix. + +For example, using a Traefik router: + +```TOML hl_lines="8" +[http] + + [http.routers] + + [http.routers.app-http] + entryPoints = ["http"] + service = "app" + rule = "PathPrefix(`/api/v1`)" + + [http.services] + + [http.services.app] + [http.services.app.loadBalancer] + [[http.services.app.loadBalancer.servers]] + url = "http://127.0.0.1:8000" +``` + +This router config tells Traefik to forward all requests starting with `/api/v1` to your FastAPI service. In this case, the original path `/app` would actually be served at `/api/v1/app`. @@ -117,15 +150,56 @@ Even though all your code is written assuming there's just `/app`. {* ../../docs_src/behind_a_proxy/tutorial001.py hl[6] *} -And the proxy would be **"stripping"** the **path prefix** on the fly before transmitting the request to the app server (probably Uvicorn via FastAPI CLI), keeping your application convinced that it is being served at `/app`, so that you don't have to update all your code to include the prefix `/api/v1`. +...but your app only knows routes like `/app` and has no idea about the `/api/v1` prefix. -Up to here, everything would work as normally. +As a result, the app won't be able to match the routes correctly, and clients will get `404 Not Found` errors 😱. -But then, when you open the integrated docs UI (the frontend), it would expect to get the OpenAPI schema at `/openapi.json`, instead of `/api/v1/openapi.json`. +### Stripping the prefix -So, the frontend (that runs in the browser) would try to reach `/openapi.json` and wouldn't be able to get the OpenAPI schema. +One simple way to solve the problem described above is to configure the proxy to strip the prefix before forwarding the request to the app. +For example, if the client requests `/api/v1/app`, the proxy forwards it as just `/app`. -Because we have a proxy with a path prefix of `/api/v1` for our app, the frontend needs to fetch the OpenAPI schema at `/api/v1/openapi.json`. +```TOML hl_lines="2-5 13" +[http] + [http.middlewares] + + [http.middlewares.api-stripprefix.stripPrefix] + prefixes = ["/api/v1"] + + [http.routers] + + [http.routers.app-http] + entryPoints = ["http"] + service = "app" + rule = "PathPrefix(`/api/v1`)" + middlewares = ["api-stripprefix"] + + [http.services] + + [http.services.app] + [http.services.app.loadBalancer] + [[http.services.app.loadBalancer.servers]] + url = "http://127.0.0.1:8000" +``` + +The proxy would be **"stripping"** the **path prefix** on the fly before transmitting the request to the app server (probably Uvicorn via FastAPI CLI), keeping your application convinced that it is being served at `/app`, so that you don't have to update all your code to include the prefix `/api/v1`. + +Now the app's routing works perfectly - `/app` matches exactly as expected. 🎉 + +But... something is still off... + +### When the app doesn't know it's running under a prefix + +While stripping the prefix by the proxy fixes incoming requests, the app still doesn't know that it's being served under `/api/v1`. +So any URLs generated inside the app - like those from `url_for()`, URL of `openapi.json` in docs, or redirects - will miss the prefix. +Clients will get links like `/app` instead of `/api/v1/app`, breaking navigation and documentation. + +Let's look closer at the problem with URL of `openapi.json` in docs. + +When you open the integrated docs UI (the frontend), it expects to get the OpenAPI schema at `/openapi.json`. +But, since your app is served under the `/api/v1` prefix, the correct URL of `openapi.json` would be `/api/v1/openapi.json`. + +So the frontend, running in the browser, will try to reach `/openapi.json` and fail to get the OpenAPI schema. ```mermaid graph LR @@ -144,28 +218,67 @@ The IP `0.0.0.0` is commonly used to mean that the program listens on all the IP /// -The docs UI would also need the OpenAPI schema to declare that this API `server` is located at `/api/v1` (behind the proxy). For example: +At this point, it becomes clear that we need to tell the app the path prefix on which it's running. -```JSON hl_lines="4-8" +Luckily, this problem isn't new - and the people who designed the ASGI specification have already thought about it. + +### Understanding `root_path` in ASGI + +ASGI defines two fields that make it possible for applications to know where they are mounted and still handle requests correctly: + +* `path` – the full path requested by the client (including the prefix) +* `root_path` – the mount point (the prefix itself) under which the app is served + +With this information, the application always knows both: + +* what the user actually requested (`path`), and +* where the app lives in the larger URL structure (`root_path`). + +For example, if the client requests: + +``` +/api/v1/app +``` + +the ASGI server should pass this to the app as: + +``` { - "openapi": "3.1.0", - // More stuff here - "servers": [ - { - "url": "/api/v1" - } - ], - "paths": { - // More stuff here - } + "path": "/api/v1/app", + "root_path": "/api/v1", + ... } ``` -In this example, the "Proxy" could be something like **Traefik**. And the server would be something like FastAPI CLI with **Uvicorn**, running your FastAPI application. +This allows the app to: -### Providing the `root_path` { #providing-the-root-path } +* Match routes correctly (`/app` inside the app). +* Generate proper URLs and redirects that include the prefix (`/api/v1/app`). -To achieve this, you can use the command line option `--root-path` like: +This is the elegant mechanism that makes it possible for ASGI applications - including FastAPI - to work smoothly behind reverse proxies or under nested paths without needing to rewrite routes manually. + +### Providing the `root_path` + +So, the ASGI scope needs to contain the correct `path` and `root_path`. +But... who is actually responsible for setting them? 🤔 + +As you may recall, there are three main components involved in serving your FastAPI application: + +* **Reverse proxy** (like Traefik or Nginx) - receives client requests first and passes them to the ASGI server. +* **ASGI server** (like Uvicorn or Hypercorn) - runs your FastAPI application and manages the ASGI lifecycle. +* **ASGI app** itself (the app you're building with FastAPI, your favorite framework) - handles routing, generates URLs, and processes requests. + +There are a few common ways these components can work together to ensure `root_path` is set correctly: + +1. The proxy strips the prefix, and the ASGI server (e.g., Uvicorn) adds it back and sets `root_path` in the ASGI `scope`. +2. The proxy keeps the prefix and forwards requests as-is, while the ASGI app is started with the correct `root_path` parameter. +3. The proxy keeps the prefix but also sends an `X-Forwarded-Prefix` header, and a middleware in the app uses that header to dynamically set `root_path`. + +Let's look at each of these approaches in detail. + +#### Uvicorn `--root-path` (proxy strips prefix) { #uvicorn-root-path-proxy-strips-prefix } + +If your proxy removes the prefix before forwarding requests, you should use the `--root-path` option of your ASGI server:
@@ -179,23 +292,28 @@ $ fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1 If you use Hypercorn, it also has the option `--root-path`. -/// note | Technical Details +Here's what happens in this setup: -The ASGI specification defines a `root_path` for this use case. +* The proxy sends `/app` to the ASGI server (Uvicorn). +* Uvicorn adds the prefix to `path` and sets `root_path` to `/api/v1` in the ASGI scope. +* Your FastAPI app receives: -And the `--root-path` command line option provides that `root_path`. +``` +{ + "path": "/api/v1/app", + "root_path": "/api/v1", +} +``` -/// +✅ This is fully compliant with the ASGI specification. -### Checking the current `root_path` { #checking-the-current-root-path } +FastAPI automatically respects `root_path` in the scope when matching routes or generating URLs. -You can get the current `root_path` used by your application for each request, it is part of the `scope` dictionary (that's part of the ASGI spec). - -Here we are including it in the message just for demonstration purposes. +Run the following example app: {* ../../docs_src/behind_a_proxy/tutorial001.py hl[8] *} -Then, if you start Uvicorn with: +with the command:
@@ -207,7 +325,7 @@ $ fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1
-The response would be something like: +The response will look something like this: ```JSON { @@ -216,38 +334,187 @@ The response would be something like: } ``` -### Setting the `root_path` in the FastAPI app { #setting-the-root-path-in-the-fastapi-app } +/// warning | Attention -Alternatively, if you don't have a way to provide a command line option like `--root-path` or equivalent, you can set the `root_path` parameter when creating your FastAPI app: +Don't forget - this setup assumes your proxy **removes** the `/api/v1` prefix before forwarding requests to the app. + +If the proxy **doesn't strip** the prefix, you will end up with duplicated paths like `/api/v1/api/v1/app`. + +And if you run the server **without a proxy at all**, your app will still handle requests to URLs like `/app`, +but URL generation, redirects, and the interactive docs will break due to the missing prefix configuration. + +/// + +/// tip + +This is the most straightforward and common approach. +You should probably use it unless you have a specific reason to do otherwise. + +/// + +Unfortunately, not all ASGI servers support a `--root-path` option or automatically adjust the `path` in the ASGI scope. +If your server doesn't, you can use one of the alternative approaches described below. + +#### Passing `root_path` as an argument to FastAPI (proxy keeps prefix) + +If the proxy keeps the prefix in the forwarded request: + +```TOML +[http] + + [http.routers] + + [http.routers.app-http] + entryPoints = ["http"] + service = "app" + rule = "PathPrefix(`/api/v1`)" + + [http.services] + + [http.services.app] + [http.services.app.loadBalancer] + [[http.services.app.loadBalancer.servers]] + url = "http://127.0.0.1:8000" +``` + +you can configure FastAPI like this: {* ../../docs_src/behind_a_proxy/tutorial002.py hl[3] *} -Passing the `root_path` to `FastAPI` would be the equivalent of passing the `--root-path` command line option to Uvicorn or Hypercorn. +In this setup: -### About `root_path` { #about-root-path } +The app receives requests with `/api/v1` included in the path. -Keep in mind that the server (Uvicorn) won't use that `root_path` for anything else than passing it to the app. +FastAPI uses the `root_path` parameter to adjust the ASGI scope so that routing, redirects, and URL generation work correctly. -But if you go with your browser to http://127.0.0.1:8000/app you will see the normal response: +Without the `root_path` parameter, the incoming scope from the ASGI server looks like this: -```JSON +``` { - "message": "Hello World", - "root_path": "/api/v1" + "path": "/api/v1/app", + "root_path": "", } ``` -So, it won't expect to be accessed at `http://127.0.0.1:8000/api/v1/app`. +This scope is not compliant with the ASGI specification. -Uvicorn will expect the proxy to access Uvicorn at `http://127.0.0.1:8000/app`, and then it would be the proxy's responsibility to add the extra `/api/v1` prefix on top. +But thanks to the `root_path` parameter, FastAPI corrects the scope to: -## About proxies with a stripped path prefix { #about-proxies-with-a-stripped-path-prefix } +``` +{ + "path": "/api/v1/app", + "root_path": "/api/v1", +} +``` -Keep in mind that a proxy with stripped path prefix is only one of the ways to configure it. +/// warning | Attention -Probably in many cases the default will be that the proxy doesn't have a stripped path prefix. +Don't forget - this setup assumes your app runs behind a proxy that **keeps (adds)** the `/api/v1` prefix when forwarding requests. -In a case like that (without a stripped path prefix), the proxy would listen on something like `https://myawesomeapp.com`, and then if the browser goes to `https://myawesomeapp.com/api/v1/app` and your server (e.g. Uvicorn) listens on `http://127.0.0.1:8000` the proxy (without a stripped path prefix) would access Uvicorn at the same path: `http://127.0.0.1:8000/api/v1/app`. +If the proxy **strips the prefix** or **doesn't add it at all**, your app might seem to work for some routes, but features like interactive docs, mounted sub-applications, and redirects will fail. + +If you encounter strange issues with this configuration, double-check your proxy settings. + +/// + +/// note + +Use this approach when your ASGI server doesn't support a `--root-path` option, or if you need to configure your reverse proxy to keep the prefix in the path. + +Otherwise, prefer using the `--root-path` approach [described above](#uvicorn-root-path-proxy-strips-prefix){.internal-link target=_blank}. + +/// + +#### Using `X-Forwarded-Prefix` header + +This is another common approach. It's the most flexible, but requires a more initial configuration. + +/// warning + +This is a more advanced approach. In most cases, you should use the `--root-path` option [described above](#uvicorn-root-path-proxy-strips-prefix){.internal-link target=_blank}. + +/// + +Imagine the prefix you use to serve the app might change over time. With the `--root-path` approach, you would need to update both the proxy configuration and the ASGI server command each time the prefix changes. + +Or, suppose you want to serve your app under multiple prefixes, such as `/api/v1` and `/backend/v1`. You would then need multiple instances of your app configured with different `--root-path` values - not ideal. + +There is a better solution for this! 💡 +Configure your reverse proxy to send the mount prefix in a header, e.g.: + +``` +X-Forwarded-Prefix: /api/v1 +``` + +Here is an example Traefik configuration: + +```TOML +[http] + + [http.routers] + + [http.routers.app-api-v1] + entryPoints = ["http"] + service = "app" + rule = "PathPrefix(`/api/v1`)" + middlewares = ["prefix-api-v1"] + + [http.routers.app-backend-v1] + entryPoints = ["http"] + service = "app" + rule = "PathPrefix(`/backend/v1`)" + middlewares = ["prefix-backend-v1"] + + [http.services] + + [http.services.app.loadBalancer] + [[http.services.app.loadBalancer.servers]] + url = "http://127.0.0.1:8000" + + [http.middlewares] + + [http.middlewares.prefix-api-v1.headers.customRequestHeaders] + X-Forwarded-Prefix = "/api/v1" + + [http.middlewares.prefix-backend-v1.headers.customRequestHeaders] + X-Forwarded-Prefix = "/backend/v1" +``` + +/// note + +`X-Forwarded-Prefix` is not a part of any standard, but it is widely recognized for informing an app of its URL path prefix. + +/// + +You can then use middleware to read this header and dynamically set the correct `root_path`: + +{* ../../docs_src/behind_a_proxy/tutorial005.py *} + +This allows a single FastAPI instance to handle requests under multiple prefixes, with `root_path` correctly set for each request. + +Run the server: + +
+ +```console +$ fastapi run main.py --forwarded-allow-ips="*" + +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +``` + +
+ +Test with `curl`: + +``` +curl -H "x-forwarded-prefix: /api/v1" http://127.0.0.1:8000/api/v1/app +# {"message":"Hello World","root_path":"/api/v1"} + +curl -H "x-forwarded-prefix: /backend/v1" http://127.0.0.1:8000/backend/v1/app +# {"message":"Hello World","root_path":"/backend/v1"} +``` + +The same FastAPI instance now handles requests for multiple prefixes, using the correct `root_path` each time. ## Testing locally with Traefik { #testing-locally-with-traefik } diff --git a/docs_src/behind_a_proxy/tutorial005.py b/docs_src/behind_a_proxy/tutorial005.py new file mode 100644 index 000000000..6548d4d7b --- /dev/null +++ b/docs_src/behind_a_proxy/tutorial005.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, Request +from starlette.types import ASGIApp, Receive, Scope, Send + + +class ForwardedPrefixMiddleware: + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] in ("http", "websocket"): + scope_headers: list[tuple[bytes, bytes]] = scope.get("headers", []) + headers = { + k.decode("latin-1"): v.decode("latin-1") for k, v in scope_headers + } + prefix = headers.get("x-forwarded-prefix", "").rstrip("/") + if prefix: + scope["root_path"] = prefix + await self.app(scope, receive, send) + + +app = FastAPI() +app.add_middleware(ForwardedPrefixMiddleware) + + +@app.get("/app") +def read_main(request: Request): + return {"message": "Hello World", "root_path": request.scope.get("root_path")} diff --git a/tests/test_tutorial/test_behind_a_proxy/test_tutorial005.py b/tests/test_tutorial/test_behind_a_proxy/test_tutorial005.py new file mode 100644 index 000000000..1bc8504d0 --- /dev/null +++ b/tests/test_tutorial/test_behind_a_proxy/test_tutorial005.py @@ -0,0 +1,58 @@ +import pytest +from fastapi.testclient import TestClient + +from docs_src.behind_a_proxy.tutorial005 import app + +client = TestClient(app) + + +@pytest.mark.parametrize( + "root_path, requested_path, expected_root_path", + [ + ("/api/v1", "/api/v1/app", "/api/v1"), + ("/backend/v1", "/backend/v1/app", "/backend/v1"), + ("/backend/v1/", "/backend/v1/app", "/backend/v1"), + (None, "/app", ""), + ], +) +def test_forwarded_prefix_middleware( + root_path: str, + requested_path: str, + expected_root_path: str, +): + client = TestClient(app) + headers = {} + if root_path: + headers["x-forwarded-prefix"] = root_path + response = client.get(requested_path, headers=headers) + assert response.status_code == 200 + assert response.json()["root_path"] == expected_root_path + + +def test_openapi_servers(): + client = TestClient(app) + headers = {"x-forwarded-prefix": "/api/v1"} + response = client.get("/api/v1/openapi.json", headers=headers) + assert response.status_code == 200 + openapi_data = response.json() + assert "servers" in openapi_data + assert openapi_data["servers"] == [{"url": "/api/v1"}] + + +@pytest.mark.parametrize( + "root_path, requested_path, expected_openapi_url", + [ + ("/api/v1", "/api/v1/docs", "/api/v1/openapi.json"), + (None, "/docs", "/openapi.json"), + ], +) +def test_swagger_docs_openapi_url( + root_path: str, requested_path: str, expected_openapi_url: str +): + client = TestClient(app) + headers = {} + if root_path: + headers["x-forwarded-prefix"] = root_path + response = client.get(requested_path, headers=headers) + assert response.status_code == 200 + assert expected_openapi_url in response.text