Docker 35 🐳 Container Port Forwarding Mechanics: iptables Rules, NAT, and docker-proxy
A published port is a promise. The container’s application listens on a port inside its own network namespace, and the host promises that a connection to a specific host port will reach it. The mechanism behind that promise is a set of iptables rules in the host’s NAT table, a connection-tracking system, and—for the traffic that the NAT rules cannot handle—a userland proxy process called docker-proxy. Understanding these three components is understanding what happens between the client’s SYN packet and the container’s listening socket.
Key point: Docker uses iptables DNAT rules in the host’s nat table to rewrite the destination address of packets arriving on a published host port, redirecting them to the container’s IP and port. A MASQUERADE rule rewrites the source address of the container’s outbound traffic. The docker-proxy process handles the traffic that the NAT rules cannot: connections originating from the host itself (hairpin NAT) and some IPv6 scenarios. The NAT rules are the fast path; the proxy is the fallback.
Why port forwarding mechanics matter
The debugging problem. A published port that does not work can fail at several layers: the application may not be listening, the iptables rules may be missing, the firewall may be blocking, or the connection tracking table may be exhausted. Knowing which layer is responsible requires knowing how the layers fit together. The iptables -t nat -L DOCKER -n command shows the DNAT rules, and the docker exec <id> ss -tlnp command shows the container’s listeners . The gap between them is where the problem lives.
The hairpin problem. A container on a bridge network cannot reach another container’s published port through the host’s IP address by relying on the DNAT rules alone. The DNAT rules match packets arriving on the host’s external interfaces, not packets generated by the host itself. The docker-proxy process exists to handle this case: it binds the host port and forwards the connection into the container .
The performance problem. The --userland-proxy=false flag disables the proxy. When the proxy is disabled, Docker uses an additional MASQUERADE rule and the net.ipv4.route_localnet kernel parameter to handle the host-to-container connections. The pure iptables path is faster than the userland proxy, which is why the Docker documentation notes that the alternative is “preferred for performance reasons” .
The security problem. The default DNAT rules bind to 0.0.0.0, which means the published port is reachable from any interface. The -p IP:host_port:container_port syntax restricts the binding to a specific host IP. The DOCKER-USER chain is the place for the user-defined firewall rules that filter the forwarded traffic .
a. The iptables NAT rules: DNAT, MASQUERADE, and FORWARD
When a container is started with -p 8080:80, Docker creates several iptables rules. The primary rule is a DNAT rule in the nat table’s DOCKER chain.
sudo iptables -t nat -L DOCKER -n -v
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
The rule matches TCP packets with a destination port of 8080 and rewrites the destination address to 172.17.0.2:80, the container’s IP and port . The packet now appears to be addressed to the container, and the host’s routing forwards it.
The PREROUTING chain in the nat table jumps to the DOCKER chain for packets that arrive on an interface and are not already destined for the host itself.
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
The ADDRTYPE match dst-type LOCAL condition matches packets whose destination is a local address on the host. The DOCKER chain then applies the DNAT rules .
The MASQUERADE rule handles the outbound direction. When a container initiates a connection to an external host, the source address is the container’s private IP (172.17.0.2). The external host cannot route a response to that address. The MASQUERADE rule in the POSTROUTING chain rewrites the source address to the host’s IP .
Chain POSTROUTING (policy ACCEPT)
target prot opt in out source destination
MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
The !docker0 condition means the rule applies to packets leaving through any interface except the Docker bridge. The container’s outbound traffic is rewritten to appear as if it originated from the host .
The FORWARD chain in the filter table controls whether the DNAT’d packets are actually allowed to pass through the host. Docker sets the default policy of FORWARD to DROP and adds rules to the DOCKER-FORWARD chain that accept the established connections and the new connections to the published ports .
The DOCKER-USER chain is the place for the user-defined rules. It is processed before Docker’s own forwarding rules, which means a rule in DOCKER-USER can block or allow traffic that Docker’s default rules would otherwise accept .
b. The docker-proxy process and hairpin NAT
The docker-proxy process is a userland TCP/UDP proxy. It is created for each published port when the --userland-proxy option is enabled (the default on Linux) .
ps aux | grep docker-proxy
# /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
The proxy binds the host port and listens for connections. When a connection arrives, the proxy opens a new connection to the container’s IP and port, and forwards the data between the two .
The proxy exists because the DNAT rules do not match all traffic. The DNAT rules in the PREROUTING chain match packets that arrive on an interface. A connection that originates from the host itself—curl http://localhost:8080—does not traverse PREROUTING in the same way. The OUTPUT chain in the nat table has its own jump to the DOCKER chain, but the DNAT for local traffic is handled differently .
The docker-proxy process handles this hairpin traffic. It binds the host port, and the host’s connection to localhost:8080 reaches the proxy. The proxy then forwards the connection to the container .
The --userland-proxy=false flag disables the proxy. When the proxy is disabled, Docker relies entirely on the iptables rules. To handle the host-to-container connections, Docker uses an additional MASQUERADE rule and the net.ipv4.route_localnet kernel parameter. The parameter allows the host to route traffic to the loopback address, and the MASQUERADE rule rewrites the source address of the container’s response. This alternative is faster than the userland proxy .
The docker-proxy process was historically started before the iptables rules were created. A commit in the Docker history changed this: the daemon now binds the socket first, then creates the iptables rules, then hands the socket to the proxy. The change prevents a race condition where the proxy accepts a connection before the NAT rules divert the traffic .
c. The connection tracking and the diagnostic path
The conntrack system tracks the connections that pass through the NAT rules. The first packet of a connection is processed by the DNAT rule, and the connection is recorded in the conntrack table. The subsequent packets of the same connection are matched by the ESTABLISHED,RELATED rule and are not re-processed by the DNAT rules .
The conntrack table has a maximum size, configured by nf_conntrack_max. When the table is full, new connections are dropped. The symptom is intermittent connection hangs that resolve after an idle period. The check is /proc/sys/net/netfilter/nf_conntrack_count versus nf_conntrack_max .
The diagnostic path for a published port that does not work:
| Check | Command | What It Tells |
|---|---|---|
| Mapping exists | docker ps --format '{{.Ports}}' | The -p flag was applied |
| Application listens | docker exec <id> ss -tlnp | The app binds 0.0.0.0, not 127.0.0.1 |
| DNAT rules exist | sudo iptables -t nat -L DOCKER -n -v | The rules are present |
| FORWARD policy | sudo iptables -L FORWARD -n | Docker’s rules are not blocked |
| IP forwarding | sysctl net.ipv4.ip_forward | The kernel forwards packets |
| Conntrack | cat /proc/sys/net/netfilter/nf_conntrack_count | The table is not full |
| Port conflict | sudo ss -tlnp | grep <port> | No other process owns the port |
The most common failure is the application binding to 127.0.0.1 inside the container. The DNAT rule delivers the packet to the container’s eth0 interface, but the application is listening only on the loopback. The connection is refused. The application must bind to 0.0.0.0 or the container’s specific IP .
The second most common failure is a host firewall that overrides Docker’s iptables rules. The FORWARD chain policy may be DROP, and the Docker rules that accept the forwarded traffic may be missing or overridden by the firewall’s own rules .
Complete Example Session
# ============================================
# PART 1: START A CONTAINER WITH A PUBLISHED PORT
# ============================================
docker run -d --name web -p 8080:80 nginx
docker ps --format '{{.Names}}: {{.Ports}}'
# web: 0.0.0.0:8080->80/tcp
# ============================================
# PART 2: VIEW THE DNAT RULE
# ============================================
sudo iptables -t nat -L DOCKER -n -v
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
# ============================================
# PART 3: VIEW THE MASQUERADE RULE
# ============================================
sudo iptables -t nat -L POSTROUTING -n -v
# MASQUERADE all -- !docker0 172.17.0.0/16 0.0.0.0/0
# ============================================
# PART 4: VIEW THE DOCKER-PROXY PROCESS
# ============================================
ps aux | grep docker-proxy
# /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
# ============================================
# PART 5: TEST FROM THE HOST (HAIRPIN)
# ============================================
curl -I http://localhost:8080
# HTTP/1.1 200 OK
# ============================================
# PART 6: TEST FROM A REMOTE HOST (DNAT)
# ============================================
# On another machine:
curl -I http://192.168.1.10:8080
# HTTP/1.1 200 OK
# ============================================
# PART 7: DISABLE USERLAND PROXY
# ============================================
# In /etc/docker/daemon.json:
# { "userland-proxy": false }
sudo systemctl restart docker
docker run -d --name web -p 8080:80 nginx
# ============================================
# PART 8: CHECK CONNTRACK
# ============================================
echo "$(cat /proc/sys/net/netfilter/nf_conntrack_count) / $(cat /proc/sys/net/netfilter/nf_conntrack_max)"
# ============================================
# PART 9: CHECK IP FORWARDING
# ============================================
sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 1
# ============================================
# PART 10: ADD A DOCKER-USER RULE
# ============================================
sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.50 -j ACCEPT
sudo iptables -I DOCKER-USER 2 -i eth0 -j DROP
The ten parts covered the published port, the DNAT rule, the MASQUERADE rule, the docker-proxy process, the hairpin test, the remote test, disabling the proxy, checking conntrack, checking IP forwarding, and adding a DOCKER-USER rule.
Quick Reference
Port Forwarding Components
| Component | Role |
|---|---|
| DNAT rule | Rewrites destination to container IP |
| MASQUERADE rule | Rewrites source for outbound |
| FORWARD chain | Allows forwarded packets |
docker-proxy | Handles hairpin and some IPv6 |
conntrack | Tracks connections |
Key iptables Commands
| Command | Purpose |
|---|---|
iptables -t nat -L DOCKER -n -v | View DNAT rules |
iptables -t nat -L POSTROUTING -n -v | View MASQUERADE rules |
iptables -L FORWARD -n | View forwarding policy |
iptables -L DOCKER-USER -n | View user rules |
iptables -t nat -D DOCKER <n> | Delete a DNAT rule |
The Diagnostic Path
| Check | Command |
|---|---|
| Mapping | docker ps --format '{{.Ports}}' |
| Listener | docker exec <id> ss -tlnp |
| DNAT | sudo iptables -t nat -L DOCKER -n |
| Forward | sudo iptables -L FORWARD -n |
| Conntrack | cat /proc/sys/net/netfilter/nf_conntrack_count |
| Port conflict | sudo ss -tlnp | grep <port> |
Proxy vs Pure iptables
| Aspect | Proxy | Pure iptables |
|---|---|---|
| Flag | --userland-proxy=true | --userland-proxy=false |
| Hairpin | Handled | MASQUERADE + route_localnet |
| Performance | Slower | Faster |
| Default | Linux: enabled | — |
Best Practices
✅ Do This:
# Check the DNAT rule after starting
sudo iptables -t nat -L DOCKER -n -v # ✅
# Verify the application listens on 0.0.0.0
docker exec web ss -tlnp # ✅
# Use DOCKER-USER for custom firewall rules
sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.50 -j ACCEPT # ✅
# Check conntrack before diagnosing intermittent hangs
cat /proc/sys/net/netfilter/nf_conntrack_count # ✅
# Use --userland-proxy=false for performance
# In /etc/docker/daemon.json # ✅
❌ Don’t Do This:
# Don't bind the application to 127.0.0.1 inside the container
# The DNAT delivers to eth0, not loopback # ❌
# Don't forget that Docker sets FORWARD policy to DROP
# A custom firewall may override it # ⚠️
# Don't edit the DOCKER chain directly
# Docker recreates the rules on restart # ⚠️
# Don't ignore conntrack exhaustion
# Intermittent hangs are a symptom # ⚠️
# Don't rely on the proxy for performance
# Disable it for the faster path # ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Connection refused | App binds 127.0.0.1 | Bind 0.0.0.0 |
| Timeout | Firewall blocks FORWARD | Check DOCKER-USER |
| Hairpin fails | No proxy, no route_localnet | Enable proxy or route_localnet |
| Intermittent hangs | Conntrack table full | Increase nf_conntrack_max |
| Rules missing after reload | Firewalld reloaded | Restart Docker |
| Port shadowed | No iptables rules with --userland-proxy=false | Check DNAT rules |
Real-World Examples
1. View DNAT Rule
sudo iptables -t nat -L DOCKER -n -v
2. View MASQUERADE
sudo iptables -t nat -L POSTROUTING -n -v
3. Check Listener
docker exec web ss -tlnp
4. Check Proxy
ps aux | grep docker-proxy
5. Disable Proxy
{ "userland-proxy": false }
6. Check Conntrack
cat /proc/sys/net/netfilter/nf_conntrack_count
7. Restrict to IP
sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.50 -j ACCEPT
8. Check Forward Policy
sudo iptables -L FORWARD -n
9. Check IP Forwarding
sysctl net.ipv4.ip_forward
10. Test Remote Access
curl -I http://server-ip:8080
Visual
Port Forwarding Data Path
┌─────────────────────────────────────────────────────────────┐
│ REMOTE CLIENT │
│ │ │
│ ▼ TCP SYN to host:8080 │
│ HOST PREROUTING (nat) │
│ │ │
│ ▼ Jump to DOCKER chain │
│ DOCKER chain: DNAT to 172.17.0.2:80 │
│ │ │
│ ▼ Destination rewritten │
│ ROUTING DECISION │
│ │ │
│ ▼ Forward to docker0 │
│ CONTAINER eth0:80 │
│ │
│ The DNAT rule rewrites the destination address. │
│ │
└─────────────────────────────────────────────────────────────┘
Hairpin NAT and docker-proxy
┌─────────────────────────────────────────────────────────────┐
│ HOST │
│ │ │
│ ├── curl localhost:8080 │
│ │ │ │
│ │ ▼ │
│ │ docker-proxy (binds :8080) │
│ │ │ │
│ │ ▼ │
│ │ Opens connection to 172.17.0.2:80 │
│ │ │ │
│ │ ▼ │
│ │ CONTAINER eth0:80 │
│ │ │
│ └── Remote client (DNAT path) │
│ │
│ The proxy handles the host's own connections. │
│ │
└─────────────────────────────────────────────────────────────┘
NAT Rule Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ docker run -p 8080:80 nginx │
│ │ │
│ ▼ │
│ Create iptables rules: │
│ - DNAT: 8080 → 172.17.0.2:80 │
│ - MASQUERADE: 172.17.0.2 → host │
│ - FORWARD: allow established, allow new │
│ │ │
│ ▼ │
│ Start docker-proxy (if enabled) │
│ │ │
│ ▼ │
│ Container running │
│ │
│ On container stop: │
│ - Delete DNAT rule │
│ - Delete MASQUERADE rule │
│ - Delete FORWARD rules │
│ - Stop docker-proxy │
│ │
└─────────────────────────────────────────────────────────────┘
The Diagnostic Flow
┌─────────────────────────────────────────────────────────────┐
│ Connection fails │
│ │ │
│ ├── docker ps shows mapping? │
│ │ ├── NO ──▶ Re-run with -p │
│ │ └── YES │
│ │ │ │
│ │ ▼ │
│ │ App listens on 0.0.0.0? │
│ │ ├── NO ──▶ Fix app bind address │
│ │ └── YES │
│ │ │ │
│ │ ▼ │
│ │ DNAT rule exists? │
│ │ ├── NO ──▶ Restart Docker │
│ │ └── YES │
│ │ │ │
│ │ ▼ │
│ │ Conntrack table full? │
│ │ ├── YES ──▶ Increase nf_conntrack_max │
│ │ └── NO ──▶ Check firewall (DOCKER-USER) │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| DNAT rule | Rewrites destination to container IP |
| MASQUERADE rule | Rewrites source for outbound |
| FORWARD chain | Allows forwarded packets |
docker-proxy | Handles hairpin and IPv6 |
conntrack | Tracks connections |
DOCKER-USER | User-defined firewall rules |
--userland-proxy | Enables/disables the proxy |
| Default binding | 0.0.0.0 (all interfaces) |
| IP restriction | -p IP:host_port:container_port |
Key takeaways:
- Docker uses
iptablesDNAT rules to forward published ports. The rule in thenattable’sDOCKERchain rewrites the destination address of packets arriving on the host port to the container’s IP and port. The MASQUERADE rule handles the outbound direction, rewriting the container’s source address to the host’s IP . - The
docker-proxyprocess handles the traffic the NAT rules cannot. Connections originating from the host itself (hairpin NAT) and some IPv6 scenarios are handled by the userland proxy. The proxy binds the host port and forwards the connection to the container . --userland-proxy=falseuses a faster pure-iptablespath. The alternative uses an additional MASQUERADE rule and thenet.ipv4.route_localnetkernel parameter. The pureiptablespath is preferred for performance .- The
DOCKER-USERchain is the place for custom firewall rules. Rules inDOCKER-USERare processed before Docker’s own forwarding rules, which means they can block or allow traffic that Docker’s default rules would otherwise accept . - The
conntracktable tracks the connections. When the table is full, new connections are dropped. The symptom is intermittent hangs. The check isnf_conntrack_countversusnf_conntrack_max. - The most common failure is the application binding to
127.0.0.1. The DNAT rule delivers the packet to the container’seth0, but the application is listening only on loopback. The connection is refused. The application must bind to0.0.0.0. - The
FORWARDchain policy isDROPby default. Docker sets it and adds its own rules. A host firewall that overrides the policy or the rules can block the forwarded traffic .
Remember: A published port is not a single configuration. It is a chain of mechanisms: the DNAT rule that rewrites the destination, the MASQUERADE rule that rewrites the source, the FORWARD rules that allow the traffic, the conntrack table that tracks the connections, and the docker-proxy that handles the hairpin. When the port does not work, the failure is at one of these layers, and the diagnostic path is the order in which they are checked. The iptables -t nat -L DOCKER -n command shows the DNAT rules. The docker exec <id> ss -tlnp command shows the listener. The gap between the two is where the problem lives.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!