Update the part of "Behind a proxy" section related to `root_path`

This commit is contained in:
Yurii Motov 2025-10-27 22:35:56 +01:00
parent 78c94c3f56
commit f23fba1ead
3 changed files with 404 additions and 52 deletions

View File

@ -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. 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`. 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] *} {* ../../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 ```mermaid
graph LR 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", "path": "/api/v1/app",
// More stuff here "root_path": "/api/v1",
"servers": [ ...
{
"url": "/api/v1"
}
],
"paths": {
// More stuff here
}
} }
``` ```
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:
<div class="termy"> <div class="termy">
@ -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`. 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). Run the following example app:
Here we are including it in the message just for demonstration purposes.
{* ../../docs_src/behind_a_proxy/tutorial001.py hl[8] *} {* ../../docs_src/behind_a_proxy/tutorial001.py hl[8] *}
Then, if you start Uvicorn with: with the command:
<div class="termy"> <div class="termy">
@ -207,7 +325,7 @@ $ fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1
</div> </div>
The response would be something like: The response will look something like this:
```JSON ```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] *} {* ../../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 <a href="http://127.0.0.1:8000" class="external-link" target="_blank">http://127.0.0.1:8000/app</a> 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", "path": "/api/v1/app",
"root_path": "/api/v1" "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:
<div class="termy">
```console
$ fastapi run main.py --forwarded-allow-ips="*"
<span style="color: green;">INFO</span>: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
```
</div>
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 } ## Testing locally with Traefik { #testing-locally-with-traefik }

View File

@ -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")}

View File

@ -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