Why does uvicorn or gunicorn show 127.0.0.1 or 172.17.0.1 as the client IP behind nginx?
Uvicorn trusts X-Forwarded-For only from 127.0.0.1 and ::1; Gunicorn's WSGI workers never read it. The fix per server, Docker and UNIX socket cases, and why * is risky.
Key points
- Uvicorn rewrites the client address from
X-Forwarded-Foronly when the connection comes from a peer in--forwarded-allow-ips. The default is127.0.0.1and::1. nginx in another container connects from a172.xaddress, so uvicorn ignores the header and reports nginx. - Fix it with
--forwarded-allow-ips(orFORWARDED_ALLOW_IPS) set to nginx's address or its Docker network, for example172.18.0.0/16. The same list decides whether uvicorn believesX-Forwarded-Proto. - Gunicorn's own workers (
sync,gthread,gevent) never setREMOTE_ADDRfromX-Forwarded-For, whatever--forwarded-allow-ipssays. That setting only gates the scheme headers. Use Werkzeug'sProxyFixor the framework instead. - Gunicorn with
uvicorn_worker.UvicornWorkerdoes pass gunicorn's--forwarded-allow-ipsto uvicorn. The old bug report, uvicorn issue #589, was closed in 2020, and uvicorn-worker 0.4.0 passes the value. *makes uvicorn take the leftmostX-Forwarded-Forentry, which the visitor can write. It is safe only if nobody can reach the app except through nginx and nginx overwrites the header with$remote_addr. Over a UNIX socket,*is the only value that works.
Uvicorn shows nginx's address because it only believes X-Forwarded-For from peers it trusts. By default it trusts only 127.0.0.1 and ::1. nginx in another container connects from a Docker address such as 172.18.0.3. On the host, through a published port, it shows up as the bridge gateway, 172.17.0.1 on the default bridge. Neither is on the list, so uvicorn ignores the header and reports the peer. The fix is one flag: --forwarded-allow-ips set to nginx's address or network.
Gunicorn is a different story, and that is where most of the confusion comes from. Gunicorn has a setting with the same name and the same default. But its own workers never use X-Forwarded-For to set the client address at all. When gunicorn shows 127.0.0.1, no gunicorn flag will change it.
| Server | Sets the client from X-Forwarded-For? | Trust setting | Default | Over a UNIX socket | X-Forwarded-Proto |
|---|---|---|---|---|---|
| Uvicorn 0.54.0 | Yes, when the peer is trusted | --forwarded-allow-ips or FORWARDED_ALLOW_IPS | 127.0.0.1,::1 | Peer is None, so headers are ignored unless * | Same list; only http, https, ws, wss |
Gunicorn 26.2.0 with uvicorn_worker.UvicornWorker 0.4.0 | Yes, by uvicorn's code | Gunicorn's --forwarded-allow-ips, passed to uvicorn | 127.0.0.1,::1 | Same as uvicorn | Same as uvicorn |
Gunicorn 26.2.0 sync, gthread, gevent | No. REMOTE_ADDR is always the socket peer | --forwarded-allow-ips gates only the scheme headers and forwarder_headers | 127.0.0.1,::1 | REMOTE_ADDR is empty; the socket is trusted for scheme headers | X-Forwarded-Proto: https sets wsgi.url_scheme if the peer is allowed |
| Hypercorn 0.18.0 | Only with ProxyFixMiddleware | trusted_hops, a hop count; the peer is never checked | Middleware off | Same as TCP | Same hop count; the value is not checked |
Each row is explained below, with the code behind it.
What the client IP and forwarded-allow-ips mean here#
The client IP is what your app reads as the visitor's address. In FastAPI or Starlette it is request.client.host. In Flask it is request.remote_addr, and in Django it is request.META["REMOTE_ADDR"]. All of them read one value that the server hands over. In ASGI that value is scope["client"]. In WSGI it is REMOTE_ADDR.
The server fills it from the TCP connection. The peer is the machine on the other end of that connection. Behind nginx, the peer is always nginx, never the visitor.
nginx can pass the visitor's address along in the X-Forwarded-For header. That header is a comma-separated list. Each proxy adds the address it received the request from, on the right. The X-Forwarded-For header explains the format.
Any client can send that header too, with any value in it. So a server must choose whose header to believe. --forwarded-allow-ips is that choice. It is a list of peers that may say who the visitor is. A peer on the list is a trusted proxy. A peer off the list gets its headers ignored, and the app sees the peer's own address.
Uvicorn: the default trusts loopback only#
Uvicorn's settings page says --proxy-headers is on by default, "but is restricted to only trusting connecting IPs in the forwarded-allow-ips configuration". For --forwarded-allow-ips it says: "Defaults to $FORWARDED_ALLOW_IPS if set. Otherwise, 127.0.0.1 and ::1 are trusted. The literal '*' means trust everything."
The code in uvicorn/middleware/proxy_headers.py does three things on each request:
- It reads the peer's address from
scope["client"]. - If the peer is not on the list, it does nothing. The app sees the peer.
- If the peer is on the list, it joins all
X-Forwarded-Forlines into one list. It walks that list from the right and skips every entry that is also trusted. The first entry that is not trusted becomes the client.
Step 3 is the right-to-left walk that configuring trusted proxies describes for nginx, Apache and the rest. The list takes single addresses, networks such as 10.100.0.0/16, and plain strings, called literals. Anything that is not an address or a network is kept as a literal.
Two small points come from the same code. When every entry in the header is trusted, uvicorn takes the leftmost one. That came from issue #1068, where a client running on a proxy host ended up as None. And the new client's port is the one in the header entry. With no port there, it is 0. nginx's $remote_addr carries no port, so behind nginx the port is 0. The deployment docs say the same: "the client's port will be set to 0".
Why the address is 172.x or 127.0.0.1#
Which address you see tells you which case you have.
A 172.x.x.x address from a Docker network, such as 172.18.0.3, is nginx in its own container. Each container has its own address on the network, and nginx connects from it. That is not 127.0.0.1, so uvicorn does not trust it. Uvicorn issue #589 opens with this case. "Because I'm in a container, my WSGI/ASGI server receives requests not from the localhost, but from the docker network."
172.17.0.1, or another address ending in .1, is the network's gateway. You get it when nginx runs on the host. It proxies to the app's published port, such as proxy_pass http://127.0.0.1:8000. Docker's port forwarding then opens the connection into the container from the gateway address. 172.17.0.1 is the gateway of Docker's default bridge network. A Compose network has its own, such as 172.18.0.1. We checked this on Docker 28.1.1 with the default userland-proxy setting. A request from the host to a published port arrived from the gateway address.
127.0.0.1 with uvicorn is the odd one, because uvicorn trusts 127.0.0.1 by default. If nginx and uvicorn share a host and uvicorn still shows 127.0.0.1, uvicorn is not the problem. It means one of these:
- nginx is not sending
X-Forwarded-Foron that location. nginx adds no such header unless you writeproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. - The location has its own
proxy_set_headerlines, for WebSockets for example. The nginx docs say these directives are inherited "if and only if there are noproxy_set_headerdirectives defined on the current level". OneUpgradeline in a location drops every header line set at the server level. - Uvicorn runs with
--no-proxy-headers.
127.0.0.1 with gunicorn is expected. The next section explains why.
nginx in Docker and proxy_pass localhost covers which address nginx should connect to in each layout. This page is about the address the app sees once it does.
Gunicorn's WSGI workers never read X-Forwarded-For#
Most guides get this row wrong. Gunicorn's settings page describes forwarded_allow_ips as "Front-end's IP addresses or networks from which allowed to handle set secure headers." The default is '127.0.0.1,::1'. The FORWARDED_ALLOW_IPS variable replaces it if set. The setting is about secure headers, not the client address.
The code agrees. In gunicorn/http/wsgi.py (26.2.0), REMOTE_ADDR is set from the socket peer. The X-Forwarded-For header goes to the app as is, as HTTP_X_FORWARDED_FOR. The trust check in gunicorn/http/message.py returns two things a trusted peer may set: the scheme headers, and forwarder_headers. The forwarder_headers default is SCRIPT_NAME,PATH_INFO. The client address is not one of them. The sync, gthread and gevent workers all use this same code.
So with any of these workers:
REMOTE_ADDRis nginx:127.0.0.1on one host, or nginx's container address in Docker.--forwarded-allow-ips '*'does not changeREMOTE_ADDR. It only letsX-Forwarded-Protothrough.
There are two ways to get the real address into a WSGI app:
- Fix it in the app. For Flask and other Werkzeug apps, wrap the app in
ProxyFix. Its defaults in Werkzeug 3.1.9 arex_for=1andx_proto=1: trust one proxy for each of those headers. With exactly one nginx in front,app.wsgi_app = ProxyFix(app.wsgi_app)is enough.ProxyFixcounts hops. It does not check who the peer is, so it trusts anyone who can reach gunicorn's port. Django ships no setting for this; the trusted proxies page explains why and what to do instead. - Use the PROXY protocol. Gunicorn's
--proxy-protocol(v1,v2orauto,offby default) setsREMOTE_ADDRfrom the PROXY header. The peer must be inproxy_allow_ips, which also defaults to127.0.0.1,::1. nginx'sproxy_passin anhttpblock cannot send this header. nginx'sstreammodule or HAProxy can. The PROXY protocol covers both.
Gunicorn also has its own asgi worker (-k asgi). In its gunicorn/asgi/protocol.py, the scope's client comes from the socket peer, or from the PROXY protocol when that is on. That file has no X-Forwarded-For handling. If you want gunicorn to manage processes and still read the header, use the uvicorn worker.
Gunicorn with the uvicorn worker: the setting is passed through#
Uvicorn issue #589, opened in March 2020, is titled "Uvicorn via gunicorn worker doesn't respect --forwarded-allow-ips". At the time, the worker built uvicorn's config without that value. So uvicorn fell back to its own default. The workaround in the thread was to set FORWARDED_ALLOW_IPS="*" in the environment. Both programs read that variable. The issue was closed in April 2020, and the thread points to pull request #592 as the change.
That issue no longer applies. The worker now lives in its own package, uvicorn-worker. Uvicorn 0.54.0's built-in uvicorn.workers module prints a deprecation warning that points to it. In uvicorn-worker 0.4.0, _workers.py builds the config with "forwarded_allow_ips": self.cfg.forwarded_allow_ips. That is gunicorn's own setting. Proxy headers stay on, since the worker does not change uvicorn's proxy_headers default.
So both of these work:
gunicorn -k uvicorn_worker.UvicornWorker --forwarded-allow-ips 172.18.0.0/16 main:app
FORWARDED_ALLOW_IPS=172.18.0.0/16 gunicorn -k uvicorn_worker.UvicornWorker main:appWe ran both against gunicorn 26.2.0 and uvicorn-worker 0.4.0 behind nginx, with a CIDR value and with *. Each one changed the client the app saw, and the default did not. The last comment on #589, from February 2026, reached the same answer: "Seems it is working fine."
In this mode, uvicorn's code handles the request, not gunicorn's HTTP parser. The client address and X-Forwarded-Proto follow uvicorn's rules. Gunicorn's secure_scheme_headers do not apply.
Hypercorn: a hop count, and no peer check#
Hypercorn has no forwarded-allow-ips setting. Out of the box it reports the peer, nginx. To change that, you wrap the app yourself:
from hypercorn.middleware import ProxyFixMiddleware
fixed_app = ProxyFixMiddleware(app, mode="legacy", trusted_hops=1)mode="legacy" reads X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. mode="modern" reads the RFC 7239 Forwarded header. trusted_hops is how many proxies sit in front. The hypercorn guide says it "should be set to the number of proxies that are chained in front of Hypercorn".
The code in proxy_fix.py (0.18.0) takes the entry that many places from the right. With trusted_hops=1 that is the last entry, the one nginx wrote. It does not look at the peer at all, so a UNIX socket changes nothing. Anyone who can reach hypercorn's port directly can set their own client address, scheme and Host. The guide's warning covers it: the mode and hop count "must match your setup or the user-agent (client) may be trusted". The middleware also copies X-Forwarded-Proto into the scope without checking the value.
X-Forwarded-Proto is gated by the same list#
The scheme follows the same trust rule as the address. So a wrong list causes two symptoms at once. Behind a TLS proxy, the app sees http. It builds http:// links, and it may loop on its own HTTPS redirect.
- Uvicorn reads
X-Forwarded-Protoonly from a trusted peer. It accepts onlyhttp,https,wsandwss, and drops any other value. On a WebSocket it turnshttpsintowss. When the header appears more than once, the last line wins, as the deployment docs say. - Gunicorn WSGI workers set
wsgi.url_schemetohttpswhen a trusted peer sends a header that matchessecure_scheme_headers. The default dictionary isX-FORWARDED-PROTOCOL: ssl,X-FORWARDED-PROTO: httpsandX-FORWARDED-SSL: on. The settings page warns that if two of these disagree, the request fails withInvalidSchemeHeaders. - Hypercorn with
ProxyFixMiddlewareuses the same hop count as for the address.
The redirect loop and the other X-Forwarded-* headers are covered in X-Real-IP, X-Forwarded-Proto, Host and Port.
UNIX sockets: the peer has no address#
Uvicorn's own nginx example connects through a UNIX socket, with server unix:/tmp/uvicorn.sock; in the upstream. That is the case most likely to surprise you.
A UNIX socket has no IP address. Uvicorn reads the peer in get_remote_addr. For a UNIX socket the peer name is not an address pair, so the function returns None. The deployment docs say it plainly: "When Uvicorn is running behind a UDS the initial client will be None." The middleware then checks None against the list. Its __contains__ returns False for an empty host before it looks at any entry. So:
request.clientisNonein Starlette and FastAPI. Code that readsrequest.client.hostraisesAttributeError: 'NoneType' object has no attribute 'host'.X-Forwarded-ForandX-Forwarded-Protoare ignored, so the scheme stayshttp.- Putting the socket path in
--forwarded-allow-ipsdoes not help. The docs give a socket path as an example literal. But the check compares the peer's host, and on a socket that host isNone, not the path. We tried uvicorn 0.54.0 with the socket path andunix:both on the list. The client stayedNoneand the scheme stayedhttp. --forwarded-allow-ips='*'is the only value that works over a socket.
The same holds for gunicorn with the uvicorn worker on a unix: bind. The same uvicorn code runs.
Gunicorn's WSGI workers go the other way. Their docs say forwarded_allow_ips "does not affect UNIX socket connections. Connections not associated with an IP address are treated as allowed, unconditionally." So over a socket, gunicorn believes the scheme headers with no setting at all. REMOTE_ADDR is still not taken from X-Forwarded-For. On our socket test it was an empty string.
The literals do have one use. When nginx itself listens on a UNIX socket, behind some other proxy, its $remote_addr is unix:. The deployment docs say nginx "will add the literal unix: as the client in the X-Forwarded-For header." Uvicorn's walk would stop at that entry and report unix: as the client. Add unix: to the list and the walk skips it.
Why * is risky, and when it is not#
* changes more than the peer check. Most people expect * to mean "trust every peer, then walk the list as usual". In uvicorn's code it does not. With *, get_trusted_client_address skips the walk. It returns the leftmost entry, which the visitor's own request can supply.
Now look at what nginx sends. $proxy_add_x_forwarded_for is, in the nginx docs, the client's X-Forwarded-For "with the $remote_addr variable appended to it". A visitor who sends X-Forwarded-For: 1.2.3.4 reaches uvicorn as 1.2.3.4, 203.0.113.50. With a CIDR list, uvicorn walks from the right and gets 203.0.113.50, the real address. With *, it takes 1.2.3.4. We saw this happen in our check, on TCP and on a socket alike.
So * is safe only when both of these hold:
- Nothing can reach the app except nginx. No published port, no shared network with untrusted containers, no socket that other users can open. Uvicorn creates its
--udssocket with mode0o666(fromconfig.py). Any local user who can reach the directory can connect. - nginx replaces the header instead of appending to it:
proxy_set_header X-Forwarded-For $remote_addr;. That is what uvicorn's docs mean by "your proxies remove the existing headers before setting their own".
On TCP, you rarely need *. Trust nginx's address or network instead, and keep $proxy_add_x_forwarded_for. On a UNIX socket, * is the only choice, so pair it with the overwrite. Client IP spoofing through proxies shows what an attacker does with a header like this.
Worked case: FastAPI in Docker Compose behind nginx#
Take a Compose project with two services, nginx and api. The api service runs uvicorn main:app --host 0.0.0.0 --port 8000. Suppose Docker gives the project network 172.18.0.0/16, and nginx gets 172.18.0.3. A visitor at 203.0.113.50 loads a page, and the app logs request.client.host. Walk it through.
- nginx receives the request from
203.0.113.50. Its config hasproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. The visitor sent no such header, so nginx sendsX-Forwarded-For: 203.0.113.50. - Uvicorn receives the request from
172.18.0.3. Its list is the default,127.0.0.1,::1. The peer is not on it. - The middleware does nothing. The app logs
172.18.0.3, the nginx container.
Now add the network to the trusted list in the api service:
services:
api:
command: uvicorn main:app --host 0.0.0.0 --port 8000
environment:
FORWARDED_ALLOW_IPS: "172.18.0.0/16"
# no "ports:" here; only nginx should reach port 8000- The peer
172.18.0.3is inside172.18.0.0/16, so uvicorn reads the header. - It walks
203.0.113.50from the right. That address is not trusted, so it becomes the client, with port0. The app logs203.0.113.50. - A visitor who sends
X-Forwarded-For: 1.2.3.4arrives as1.2.3.4, 203.0.113.50. The walk stops at203.0.113.50again. The fake entry on the left is never reached.
Two choices in that file matter. The subnet is a guess until you check it. docker network inspect shows the real one. Docker can pick a different one for a new project. And the api service publishes no port. If it did, a request from the Docker host itself would arrive from the gateway, 172.18.0.1. That address is inside the trusted range, so such a request could set its own client address.
Where this answer stops applying#
The rows come from the source of uvicorn 0.54.0, uvicorn-worker 0.4.0, gunicorn 26.2.0, hypercorn 0.18.0 and Werkzeug 3.1.9. Other versions can differ. The clearest case is the 2020 worker described in issue #589, which ignored gunicorn's setting. We also ran the uvicorn, gunicorn sync and uvicorn-worker rows behind nginx 1.27 on Docker 28.1.1, over TCP and over a UNIX socket. The hypercorn socket case, the gthread, gevent and asgi workers, and Docker Desktop come from reading the code only. Frameworks add a layer on top. Middleware in Starlette, Django or Flask can rewrite the client again after the server. That is outside this page. You may have more than one proxy in front, such as a CDN or a cloud load balancer. Then every hop's address has to be on the list. If one is missing, the walk stops there. This page covers the server side only. Whether nginx itself sees the real visitor is a separate question. On cloud platforms it often does not. The client IP resolver shows what a given header and trusted list resolve to.
Frequently asked questions#
Why does FastAPI request.client.host show 172.17.0.1 or 172.18.0.x?#
That is the address nginx connects from. It is nginx's container address, or the bridge gateway when nginx runs on the host and uses a published port. Uvicorn trusts only 127.0.0.1 and ::1 by default, so it ignores X-Forwarded-For from that address. Set --forwarded-allow-ips or FORWARDED_ALLOW_IPS to nginx's address or network.
Does gunicorn --forwarded-allow-ips fix REMOTE_ADDR?#
No. With gunicorn's own WSGI workers, REMOTE_ADDR is always the socket peer. The setting decides who may send the scheme headers and the forwarder_headers, nothing else. Use Werkzeug ProxyFix in Flask, middleware in Django, or the PROXY protocol.
Does the uvicorn worker for gunicorn honor --forwarded-allow-ips?#
Yes. uvicorn-worker 0.4.0 passes gunicorn's forwarded_allow_ips value to uvicorn's config. Uvicorn issue #589 reported the opposite in 2020, and it was closed that year. Setting FORWARDED_ALLOW_IPS in the environment works too, since both programs read it.
Why is request.client None with uvicorn --uds?#
A UNIX socket has no IP address, so uvicorn sets the initial client to None. Its proxy middleware treats an empty peer as untrusted, so it never reads X-Forwarded-For. Only --forwarded-allow-ips='*' makes it read the header over a socket. Pair that with proxy_set_header X-Forwarded-For $remote_addr; in nginx.
Is --forwarded-allow-ips='*' safe behind nginx?#
Only if nothing but nginx can reach the app and nginx overwrites X-Forwarded-For with $remote_addr. With *, uvicorn takes the leftmost entry. With nginx's usual $proxy_add_x_forwarded_for, that leftmost entry is whatever the visitor sent.
Why is the scheme http behind nginx with TLS?#
Uvicorn reads X-Forwarded-Proto only from peers on the same trusted list as the address. If nginx is not on the list, the scheme stays http, and so does the address. Fixing the list fixes both. nginx must also send proxy_set_header X-Forwarded-Proto $scheme;.
What should forwarded-allow-ips be in Docker Compose?#
Use nginx's network, from docker network inspect, such as 172.18.0.0/16. Or use nginx's own address if you pin it. Do not publish the app's port. Connections from the host through a published port arrive from the network gateway, which is inside that range.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- Uvicorn settings (--proxy-headers, --forwarded-allow-ips)
- Uvicorn deployment, Proxies and Forwarded Headers (client port, UNIX sockets, trust everything)
- Uvicorn 0.54.0 source, uvicorn/middleware/proxy_headers.py (ProxyHeadersMiddleware)
- Uvicorn 0.54.0 source, uvicorn/protocols/utils.py (get_remote_addr)
- Uvicorn 0.54.0 source, uvicorn/config.py (FORWARDED_ALLOW_IPS default, UNIX socket mode 0o666)
- uvicorn-worker 0.4.0 source, uvicorn_worker/_workers.py
- Uvicorn issue #589: Uvicorn via gunicorn worker doesn't respect --forwarded-allow-ips
- Uvicorn issue #1068: ProxyHeadersMiddleware with client on trusted proxy
- Gunicorn settings (forwarded_allow_ips, secure_scheme_headers, forwarder_headers, proxy_protocol)
- Gunicorn 26.2.0 source, gunicorn/http/wsgi.py (REMOTE_ADDR)
- Gunicorn 26.2.0 source, gunicorn/http/message.py (forwarded trust check)
- Gunicorn 26.2.0 source, gunicorn/asgi/protocol.py (asgi worker scope client)
- Hypercorn 0.18.0, Fixing proxy headers guide
- Hypercorn 0.18.0 source, middleware/proxy_fix.py
- Werkzeug 3.1.9 source, middleware/proxy_fix.py (ProxyFix defaults)
- nginx ngx_http_proxy_module (proxy_set_header, $proxy_add_x_forwarded_for)
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.