Skip to main content
RunBook Academy

Docker & ContainersVII Β· NetworkingPublished ports

Published ports and the userland proxy

Intermediate⏱ ~24 mindockeriptables

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

Not yet marked complete on this device.

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

Read-only / Safeboth mechanisms, one port
$ 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:80

Illustrative 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.

ConnectionNetfilter pathCarried by
From another host, arriving on eth0PREROUTING β†’ routing β†’ FORWARDKernel DNAT
From the host itself to 127.0.0.1:8080OUTPUT on loopbackdocker-proxy
From the host to its own LAN address 203.0.113.10:8080OUTPUT β†’ nat OUTPUT β†’ DOCKERKernel DNAT
From a container back to a published port on the host (hairpin)PREROUTING β†’ DNAT β†’ POSTROUTING masqueradeKernel 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.

Read-only / Safetwo clients, two source addresses
$ # 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 2
198.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:

Read-only / Safethe hairpin rule
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 MASQUERADE

The 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

Service impact possibledaemon.json
# /etc/docker/daemon.json
{
"userland-proxy": false
}
Service impact possibleapply and verify
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 DOCKER

What 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-proxy and iptables -t nat -S DOCKER both 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.1 appears 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 whatever localhost resolves to on that host.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. On a default Docker host, a client on another machine connects to a published port. Which mechanism carries the connection?

  2. 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?

  3. Q3. Which are genuine consequences of setting `"userland-proxy": false`? Select all that apply.

  4. Q4. Killing a docker-proxy process makes the published port unreachable from the internet.

  5. Q5. The `--userland-proxy` option defaults to true on Linux.

Passing score: 75%. Answers are checked in this browser.