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