Port Publishing Under the Hood: iptables DNAT, Userland Proxies, and Host Routing
Understanding destination NAT packet rewriting, DOCKER iptables chains, and docker-proxy mechanics.
Part 13 in Series — Catch up on the previous article: Container Virtual Networking: Bridge Networks, veth Pairs, and Network Namespaces (Part 12) before diving into this post.
You launch an Nginx web container published on host port 8080:
docker run -d --name web -p 8080:80 nginx
An external web browser sends a request to http://192.168.1.100:8080 (the host machine’s public IP).
The request successfully loads the Nginx welcome page.
Yet, inside the container, Nginx is listening on private IP 172.17.0.2 on port 80.
How does an incoming TCP packet addressed to physical host port 8080 get intercepted, rewritten, and routed into an isolated network namespace listening on port 80?
The answer involves two complementary networking mechanisms inside Docker: iptables Destination NAT (DNAT) and the docker-proxy Userland Daemon.
1. The Port Publishing Problem
Because containers on the default bridge network exist in a private IPv4 subnet (172.17.0.0/16), external machines on the physical local network cannot route traffic directly to container IP 172.17.0.2.
External Client (192.168.1.50)
|
v Sends HTTP Request to 192.168.1.100:8080
[ Host Physical Interface eth0 (192.168.1.100) ]
|
? How does packet reach Private IP 172.17.0.2:80?
v
[ Container Network Namespace (172.17.0.2:80) ]
To bridge this gap, Docker publishes ports using Network Address Translation (NAT).
2. Kernel Packet Rewriting via iptables DNAT
When you specify -p 8080:80, the Docker daemon inserts rule entries into the Linux kernel Netfilter iptables NAT table.
You can inspect these rules on the host using iptables:
sudo iptables -t nat -L -n -v
The DOCKER Custom Chain Output
Chain PREROUTING (policy ACCEPT)
target prot opt in out source destination
DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
target prot opt in out source destination
DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
Step-by-Step Packet Rewriting Trace
1. Incoming Packet arrives at host eth0:
Src: 192.168.1.50:54321 --> Dst: 192.168.1.100:8080
|
v
2. Netfilter PREROUTING Chain triggers DOCKER chain:
Matches rule: dpt:8080 --> Execute DNAT (Destination NAT)
|
v
3. Packet Header Rewritten in Host RAM:
Src: 192.168.1.50:54321 --> Dst: 172.17.0.2:80 <-- IP & Port Rewritten!
|
v
4. Kernel Routing Table forwards packet to docker0 gateway interface.
|
v
5. Packet arrives at Container Nginx socket on 172.17.0.2:80.
Because the packet destination is modified directly inside the Linux kernel Netfilter pipeline in RAM, DNAT operates at near wire speed with minimal CPU overhead.
3. The docker-proxy Userland Daemon
If you inspect running host processes after publishing a port (-p 8080:80), you discover an additional process running:
$ ps aux | grep docker-proxy
root 4092 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
Why does Docker spawn a separate userland daemon (docker-proxy) if iptables already performs kernel-level DNAT packet rewriting?
The Purpose of docker-proxy: Loopback & Hairpin Routing
Kernel iptables PREROUTING rules apply to packets arriving from external physical network interfaces (eth0).
However, when a host process or developer connects to localhost:8080 or 127.0.0.1:8080 locally on the host machine:
- Packets traverse the Linux Loopback Interface (
lo). - In certain Linux kernel versions or firewall configurations,
PREROUTINGchains do not trigger on loopback traffic.
docker-proxy binds to host socket 0.0.0.0:8080 as a fallback. When a local process sends data to 127.0.0.1:8080, docker-proxy accepts the TCP socket connection in userland and proxies the byte stream across to 172.17.0.2:80.
Performance Comparison: iptables DNAT vs docker-proxy
[ External Traffic ] -----------------> Netfilter iptables (Kernel Space) ---> [ Fast Wire-Speed ]
|
[ Localhost Loopback Traffic ] -------> docker-proxy (Userland Socket) -----> [ High Context-Switch Overhead ]
iptablesDNAT: Zero userland context switches. Rewrites packet headers in kernel space. Extremely fast.docker-proxy: Requires context switching between kernel space and userland process memory for every read/write socket call. High CPU overhead under heavy concurrency.
Disabling Userland Proxy: In high-throughput production hosts, userland proxying can be disabled in /etc/docker/daemon.json by setting "userland-proxy": false, forcing all traffic through kernel iptables.
Summary & Next Steps
Docker port publishing bridges physical host interfaces to private container namespaces:
-p 8080:80configures destination NAT rules inside Netfilteriptableschains.- Kernel DNAT rewrites incoming destination IP
192.168.1.100:8080to private container IP172.17.0.2:80at wire speed. docker-proxyserves as a fallback userland TCP proxy handling loopback (127.0.0.1) traffic.
In the next article, we examine User-Defined Networks and Embedded DNS Resolution in Docker.
References & Further Reading
- Docker Inc. Docker Compose Specification File Format Standard. Open Source Specification.
- Docker Inc. Docker Engine API Specification v1.43. Docker Docs.
- Moby Project. libnetwork Architecture and Service Discovery. GitHub.
Part 14: User-Defined Networks and Embedded DNS Resolution in Docker
Continue to Part 14 →