Guide
Why do my Docker container logs show 172.17.0.1 instead of the real client IP?
An outside IPv4 client reaches a published port with its own address intact. An IPv6 client, the host itself, another container or a reverse proxy does not, and each needs a different fix. Trusting the whole Docker subnet, the fix most threads suggest, let us spoof any address over IPv6.
Why do Docker container logs show 172.17.0.1 instead of the client IP?
Because the connection was handed to the container by Docker's userland proxy, docker-proxy, which opens a new connection from the network's gateway: 172.17.0.1 on the default bridge, 172.18.0.1 on the first Compose network. That happens for IPv6 clients on an IPv4-only network, for connections from the host itself and for other containers. Outside IPv4 clients are NAT'd and keep their address. If the address is your reverse proxy's instead, the app must be told to trust that proxy's X-Forwarded-For header.
Because something handed the connection to your container on someone else's behalf. 172.17.0.1 is the gateway of Docker's default bridge, and 172.18.0.1 is the gateway of the first Compose network. A container sees one of them when the connection was re-made by Docker's userland proxy, docker-proxy, which happens for IPv6 clients, for the host itself and for other containers. An outside IPv4 client reaching a published port keeps its own address. If the address you see is instead another container's, such as 172.18.0.5, that is your reverse proxy, and the fix is in the app, not in Docker.
So the first question is which address you are looking at:
- The network gateway (
x.x.x.1): the request came through docker-proxy. Usually IPv6, or a proxy on the host forwarding to127.0.0.1. - Another container's address: a reverse proxy in front. The real client is in its
X-Forwarded-Forheader, and the app has to be told to trust it.
Two answers circulate, and both cause trouble#
The first is "disable the userland proxy". It rests on a half truth. Docker publishes an IPv4 port by rewriting the packet's destination in the nat table, which leaves the source alone, so outside IPv4 clients never touch docker-proxy (it is the same rewrite that lets published ports slip past UFW). The proxy carries the paths NAT does not, and Docker's documentation names the big one: ports on the host's IPv6 addresses map to the container's IPv4 address "if no host IP is given in a port mapping, the bridge network is IPv4-only, and --userland-proxy=true (default)". Set it to false and that mapping no longer exists. Nothing gets your client's address back.
The second is set_real_ip_from 0.0.0.0/0 in nginx, or its equivalent "trust every proxy" switch in the app. That makes the forwarded header believable from anyone, so any client can write its own address. We found that trusting the narrower Docker subnet is not safe either, for a reason specific to Docker, shown below.
Which path keeps the client address, measured#
We tested on Docker Engine Engine 28.1.1 with an nginx 1.30.6 container that answers every request with $remote_addr and X-Forwarded-For. An outside client lived in a separate network namespace with 198.51.100.2 and fd00:db8:1::2. Our host's default-address-pools puts docker0 at 10.201.0.1 rather than 172.17.0.1; the user-defined network got 10.201.13.0/24.
| Who connected, and how | Address the container saw |
|---|---|
| Outside client, IPv4, published port, default bridge | 198.51.100.2, the real one |
| Outside client, IPv6, same published port | 10.201.0.1, the docker0 gateway |
| Outside client, IPv4, container on a user-defined network | 198.51.100.2 |
| Outside client, IPv6, container on a user-defined network | 10.201.13.1, that network's gateway |
The host, to 127.0.0.1 or ::1 | 10.201.0.1 |
| The host, to its own public IPv4 address | The host's public address |
| Another container, to the host's public IP and published port | 10.201.0.1, from either kind of network |
| Outside client through an nginx proxy container | 10.201.13.3 (the proxy); header 198.51.100.2 |
| Outside IPv6 client through the same proxy | 10.201.13.3; header 10.201.13.1 |
Outside IPv6 client, network created with --ipv6 | fd00:db8:1::2, the real one |
ps shows the second row's cause plainly. Docker started two proxies for one port, and the IPv6 one points at the container's IPv4 address:
/usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 18080 -container-ip 10.201.0.2 -container-port 80
/usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 18080 -container-ip 10.201.0.2 -container-port 80Look at the second proxied row. The proxy dutifully wrote the address it was given into X-Forwarded-For, and that address was already the gateway. No header setting downstream can recover an IPv6 client's address once docker-proxy has replaced it. That has to be fixed at the network.
One more side effect: the IPv6 listener is an ordinary host process. On our host, whose firewall drops inbound traffic by default, the IPv4 port answered and the IPv6 one timed out until we picked a port the INPUT rules allowed. The same port is filtered on one address family and not on the other.
Fix the IPv6 path: give the network IPv6#
Put the container that publishes the port, usually your reverse proxy, on a network with IPv6 enabled. Then ip6tables does the same destination rewrite IPv4 gets, and the source survives:
docker network create --ipv6 --subnet fd00:db8:99::/64 proxyIn Compose, the same thing is enable_ipv6: true on the network plus a subnet under ipam.config. Two things caught us out. First, the container must listen on IPv6: our test nginx had only listen 80; and the connection was refused until we added listen [::]:80;. Second, creating the network switched the host's net.ipv6.conf.all.forwarding from 0 to 1, and removing the network did not switch it back. The kernel documentation says that with forwarding on, a host ignores router advertisements unless accept_ra is 2. If your server gets its IPv6 address from router advertisements, set accept_ra=2 on that interface before you do this.
If your server has no IPv6 address at all, none of this applies: every outside client is IPv4 and already arrives intact.
Fix the proxy path: trust exactly one address#
With a reverse proxy in front, the app sees the proxy, and the client is in the header. The app has to be told which sender to believe. In nginx that is the realip module, which the official nginx image includes (nginx -V lists --with-http_realip_module):
set_real_ip_from 172.18.0.5; # the proxy container's address, nothing wider
real_ip_header X-Forwarded-For;
real_ip_recursive on;Give the proxy a fixed address (ipv4_address in Compose) so that line stays true. Here is why the width matters. We ran the same backend three ways and sent each a forged X-Forwarded-For: 6.6.6.6:
set_real_ip_from | real_ip_recursive | IPv4 client, forged header | IPv6 client, forged header |
|---|---|---|---|
| Whole network, 10.201.13.0/24 | on | 198.51.100.2 | 6.6.6.6 |
| Proxy only, 10.201.13.3 | on | 198.51.100.2 | 10.201.13.1 |
| Whole network, 10.201.13.0/24 | off | 198.51.100.2 | 10.201.13.1 |
The nginx documentation explains the first row. With recursion on, the client address is replaced by "the last non-trusted address" in the header. The proxy appended the gateway, the gateway is inside the trusted subnet, so nginx skipped it and took the forged value. Over IPv4 the last hop was the real client, which was not trusted, so the forgery stopped there. Recursion exists for chains of proxies, such as a CDN in front of your own; if you need it, trust each proxy's address, never the network's.
Also send the header the backend reads. real_ip_header defaults to X-Real-IP; with only set_real_ip_from set and a proxy that sent X-Forwarded-For, our backend kept logging the proxy. The original address stays available as $realip_remote_addr if you want both in the log.
If the proxy is nginx on the host forwarding to 127.0.0.1:<port>, the backend sees the gateway, as in the loopback row above. Trust the gateway's exact address, and only if the container's port is published to 127.0.0.1, so that nothing else reaches it.
The setting in each app that has one#
These are the profiled apps with their own trusted proxy setting, from each project's documentation.
| App | Setting | Default | What to set |
|---|---|---|---|
| Nextcloud | trusted_proxies in config.php; header from forwarded_for_headers | Header X-Forwarded-For | An array of the proxy's addresses or CIDRs |
| authentik | AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS | 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fe80::/10, ::1/128 | The proxy's address; the default covers every Docker gateway too |
| Pocket ID | TRUST_PROXY | false | The proxy's IP or CIDR. true trusts every address |
| ntfy | behind-proxy, proxy-forwarded-header, proxy-trusted-hosts | false, X-Forwarded-For, empty | behind-proxy: true; ntfy uses the rightmost address in the header |
| Firefly III | TRUSTED_PROXIES | Empty | Its .env.example says **, which trusts everyone |
| Glance | server.proxied | false | true. There is no address list |
Four of the six can be set to trust any sender: authentik out of the box for private ranges, Pocket ID with true, Firefly III with **, Glance by design. Pocket ID's own documentation says to use true only when the app is unreachable without going through your proxy. That is the rule for all four. Publish no port for the app itself, only for the proxy, and the header can only ever come from the proxy. Reverse proxy and TLS has the proxy side, including which proxies add X-Forwarded-For without being asked. Nextcloud's documentation adds its own warning: setting forwarded_for_headers wrongly "may allow clients to spoof their IP address as visible to Nextcloud, even when going through the trusted proxy".
Why this matters more than tidy logs#
A wrong client address is not cosmetic. Rate limits count every visitor as one, IP rules in an identity provider never match, and the worst case is a ban tool. Fail2ban and CrowdSec read the logged address, so if it is the proxy or the gateway, the first ban blocks everybody. CrowdSec vs Fail2ban covers both, and its advice to fix real-IP logging before turning either on is the reason this page exists.
When this answer stops applying#
- Other gateway modes. Docker's
routedmode sets up no NAT or masquerading; we tested only the defaultnatmode. - Rootless Docker, Docker Desktop, Podman. We did not test them, and their port forwarding is not the same code.
- Engine 29 and the nftables backend. Our results are from 28.1.1 with iptables.
- Other apps' parsing. We measured nginx's realip module only. How the six apps pick an address from a header with several entries is as their documentation states, and only ntfy's says.
What to do next#
Log $remote_addr (or the app's equivalent) and request the page over IPv4 and over IPv6 from outside. If IPv6 shows the gateway, enable IPv6 on the proxy's network. If both show the proxy, set the app's trusted proxy to the proxy's exact address and stop publishing the app's own port. If the gateway is a different address from the one you expected, Docker subnet clashes and address pools explains where Docker picked it, and Reaching a host service from a container covers the same gateway from the other direction.
Questions#
Does disabling userland-proxy fix the client IP in Docker?
Not for the case most people have. Outside IPv4 clients never pass through docker-proxy: their packets are rewritten in the nat table and the container already sees the real address. Docker's documentation says IPv6 host ports map to an IPv4 container only when the network is IPv4-only, no host IP is given and userland-proxy is true, the default. Turning it off removes that mapping rather than making it preserve the address. Enable IPv6 on the network instead.
Why does my container see 172.18.0.1 and not 172.17.0.1?
Because the container is on a Compose network, not the default bridge. Every network has its own gateway, and docker-proxy connects from the gateway of the network the container sits on. On a default install docker0 takes 172.17.0.0/16 and the first Compose network 172.18.0.0/16. A default-address-pools setting moves both: on our test host they were 10.201.0.1 and 10.201.13.1.
Is set_real_ip_from 0.0.0.0/0 safe?
No. It tells nginx to believe the forwarded header from anyone, so any client can choose its own address by sending one. We saw the same failure with a narrower setting: trusting the whole Docker subnet with real_ip_recursive on let an IPv6 client log itself as 6.6.6.6, because the gateway docker-proxy connects from is inside that subnet. Trust the proxy container's own address.
Why does nginx on the host make every container request come from 172.17.0.1?
Because a host proxy usually forwards to 127.0.0.1:<port>, and loopback connections to a published port go through docker-proxy, which reconnects from the container network's gateway. In our tests both 127.0.0.1 and ::1 arrived as the gateway. The client address is still in the X-Forwarded-For header the host proxy adds, so trust the gateway address in the app.
Do I need X-Real-IP or X-Forwarded-For?
Whichever your proxy sends and your app reads, and they must match. nginx's realip module defaults to real_ip_header X-Real-IP; with only set_real_ip_from set and a proxy that sent X-Forwarded-For, our backend kept logging the proxy. Most of the apps below read X-Forwarded-For. Caddy and Traefik send it without configuration.
Sources#
- Docker docs, port publishing and mapping (userland-proxy, IPv6 to IPv4)
- nginx, ngx_http_realip_module
- Nextcloud admin manual, reverse proxy configuration
- authentik configuration, AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS
- ntfy configuration, behind a proxy
- Pocket ID environment variables, TRUST_PROXY
- Glance configuration reference, server.proxied
- Firefly III .env.example, TRUSTED_PROXIES
- Linux kernel docs, IPv6 forwarding and accept_ra
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.