Docker & ContainersVII Β· NetworkingPublished ports
Published ports and the userland proxy
What you'll learn
- Explain which traffic uses docker-proxy and which uses kernel DNAT
- Predict what the container sees as the client source address
- Describe hairpin NAT and why it masks the real client
- Weigh disabling the userland proxy against the loopback and route_localnet consequences
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
When you publish a port with -p 8080:80, Docker sets up two mechanisms, not
one. A DNAT rule in the kernel, and a small userspace forwarder called
docker-proxy. Both are present in a default installation, and which one
carries a given connection depends on where the connection came from β not on
anything you configured.
Getting this split right is what separates βwhy does my access log show 172.17.0.1 for every requestβ from a five-minute fix.
What is actually running
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
ps -o pid,args -C docker-proxy
echo '---'
sudo iptables -t nat -S DOCKER PID COMMAND
2841 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
---
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80Illustrative output
One process and one rule, for one port. They are not alternatives that Docker picks between at publish time; they cover different paths.
Which path uses which
The dockerd reference describes --userland-proxy (default true) in five
words: βUse userland proxy for loopback trafficβ. That is the whole
division of labour.
| Connection | Netfilter path | Carried by |
|---|---|---|
From another host, arriving on eth0 | PREROUTING β routing β FORWARD | Kernel DNAT |
From the host itself to 127.0.0.1:8080 | OUTPUT on loopback | docker-proxy |
From the host to its own LAN address 203.0.113.10:8080 | OUTPUT β nat OUTPUT β DOCKER | Kernel DNAT |
| From a container back to a published port on the host (hairpin) | PREROUTING β DNAT β POSTROUTING masquerade | Kernel DNAT, with SNAT |
External traffic never touches docker-proxy, even though the process is
listening. The DNAT in PREROUTING rewrites the destination before the
routing decision, so the packet is forwarded to the container and the
proxyβs socket is simply never reached. The proxy exists for the case DNAT
cannot cover: locally generated traffic to 127.0.0.1, which the kernel will
not route off the loopback interface.
The source address the container sees
This is the observable consequence, and the reason most people end up reading
about docker-proxy at all.
$ # From another host on the LAN
# (run on 198.51.100.7) curl http://203.0.113.10:8080/
# From the Docker host itself
curl -s http://127.0.0.1:8080/ > /dev/null
docker logs web --tail 2198.51.100.7 - - [12/Aug/2026:09:14:02 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0"
172.17.0.1 - - [12/Aug/2026:09:14:11 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0"Illustrative output
DNAT rewrites only the destination, so an externally originated request
arrives at the container with the clientβs real address intact. Anything that
went through docker-proxy arrives from 172.17.0.1 β the docker0
gateway β because that is the proxyβs own source address for its second
connection.
Hairpin NAT
A container connecting to 203.0.113.10:8080 β the hostβs own address, for a
port published to a container on the same host β is a hairpin. The packet
leaves the bridge, gets DNATed back to a container on that same bridge, and
would arrive with a source address on the same subnet as the destination.
The container would then reply directly, and the client would drop the reply
as coming from the wrong peer.
Docker fixes this with a masquerade rule so the reply is forced back through the host:
sudo iptables -t nat -S POSTROUTING | grep -- '-s 172.17'
# -A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADEThe cost is the same one as above: the container sees the gateway address rather than the calling containerβs. Two services on the same host talking to each other through the hostβs public address is a pattern worth removing on its own merits β put both on a user-defined network and use the service name.
Disabling the proxy
# /etc/docker/daemon.json
{
"userland-proxy": false
}sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart docker
# No proxy processes should remain
pgrep -a docker-proxy || echo "no docker-proxy processes"
# The DNAT rules are still there
sudo iptables -t nat -S DOCKERWhat you gain:
- One fewer process per published port. A 50-port stack loses 50 processes. Each is small, but they are real RSS and real PIDs.
- One fewer failure domain. A wedged proxy process breaks that port; a DNAT rule has no runtime to wedge.
- Slightly lower latency on the loopback path, which is the only path the proxy was carrying anyway. External throughput does not change, because external traffic was already pure DNAT.
When you cannot disable it
- Docker Desktop on macOS and Windows. The daemon runs in a VM; the mechanism that gets traffic from the host OS into the VM is not the Linux DNAT path at all.
- A host whose local clients depend on IPv6 loopback to reach published ports, per the note above.
- Swarm mode, where the ingress routing mesh has its own expectations about the proxy. If you run Swarm, test the change on one node before applying it to the cluster.
For a plain Linux host running Compose stacks, disabling it is a reasonable
default β provided you have checked what connects to localhost: on that
host first.
Sanity check
ps -C docker-proxyandiptables -t nat -S DOCKERboth show entries for the same published port on a default host.- A request from another machine appears in the container log with the real client address.
- A request from the host to
127.0.0.1appears with the bridge gateway address. - If you disabled the proxy,
curl 127.0.0.1:<port>still works, and you checked it over IPv4 and whateverlocalhostresolves to on that host.
Knowledge check
Knowledge check Β· 5 questions
Q1. On a default Docker host, a client on another machine connects to a published port. Which mechanism carries the connection?
Q2. An nginx container published with `-p 8080:80` logs 172.17.0.1 as the client address for some requests and real addresses for others. What distinguishes them?
Q3. Which are genuine consequences of setting `"userland-proxy": false`? Select all that apply.
Q4. Killing a docker-proxy process makes the published port unreachable from the internet.
Q5. The `--userland-proxy` option defaults to true on Linux.
Passing score: 75%. Answers are checked in this browser.