| |

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:

CheckCommandWhat It Tells
Mapping existsdocker ps --format '{{.Ports}}'The -p flag was applied
Application listensdocker exec <id> ss -tlnpThe app binds 0.0.0.0, not 127.0.0.1
DNAT rules existsudo iptables -t nat -L DOCKER -n -vThe rules are present
FORWARD policysudo iptables -L FORWARD -nDocker’s rules are not blocked
IP forwardingsysctl net.ipv4.ip_forwardThe kernel forwards packets
Conntrackcat /proc/sys/net/netfilter/nf_conntrack_countThe table is not full
Port conflictsudo 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

ComponentRole
DNAT ruleRewrites destination to container IP
MASQUERADE ruleRewrites source for outbound
FORWARD chainAllows forwarded packets
docker-proxyHandles hairpin and some IPv6
conntrackTracks connections

Key iptables Commands

CommandPurpose
iptables -t nat -L DOCKER -n -vView DNAT rules
iptables -t nat -L POSTROUTING -n -vView MASQUERADE rules
iptables -L FORWARD -nView forwarding policy
iptables -L DOCKER-USER -nView user rules
iptables -t nat -D DOCKER <n>Delete a DNAT rule

The Diagnostic Path

CheckCommand
Mappingdocker ps --format '{{.Ports}}'
Listenerdocker exec <id> ss -tlnp
DNATsudo iptables -t nat -L DOCKER -n
Forwardsudo iptables -L FORWARD -n
Conntrackcat /proc/sys/net/netfilter/nf_conntrack_count
Port conflictsudo ss -tlnp | grep <port>

Proxy vs Pure iptables

AspectProxyPure iptables
Flag--userland-proxy=true--userland-proxy=false
HairpinHandledMASQUERADE + route_localnet
PerformanceSlowerFaster
DefaultLinux: 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

PitfallWhy It HappensFix
Connection refusedApp binds 127.0.0.1Bind 0.0.0.0
TimeoutFirewall blocks FORWARDCheck DOCKER-USER
Hairpin failsNo proxy, no route_localnetEnable proxy or route_localnet
Intermittent hangsConntrack table fullIncrease nf_conntrack_max
Rules missing after reloadFirewalld reloadedRestart Docker
Port shadowedNo iptables rules with --userland-proxy=falseCheck 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

ItemValue
DNAT ruleRewrites destination to container IP
MASQUERADE ruleRewrites source for outbound
FORWARD chainAllows forwarded packets
docker-proxyHandles hairpin and IPv6
conntrackTracks connections
DOCKER-USERUser-defined firewall rules
--userland-proxyEnables/disables the proxy
Default binding0.0.0.0 (all interfaces)
IP restriction-p IP:host_port:container_port

Key takeaways:

  • Docker uses iptables DNAT rules to forward published ports. The rule in the nat table’s DOCKER chain 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-proxy process 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=false uses a faster pure-iptables path. The alternative uses an additional MASQUERADE rule and the net.ipv4.route_localnet kernel parameter. The pure iptables path is preferred for performance .
  • The DOCKER-USER chain is the place for custom firewall rules. Rules in DOCKER-USER are processed before Docker’s own forwarding rules, which means they can block or allow traffic that Docker’s default rules would otherwise accept .
  • The conntrack table tracks the connections. When the table is full, new connections are dropped. The symptom is intermittent hangs. The check is nf_conntrack_count versus nf_conntrack_max .
  • The most common failure is the application binding to 127.0.0.1. The DNAT rule delivers the packet to the container’s eth0, but the application is listening only on loopback. The connection is refused. The application must bind to 0.0.0.0 .
  • The FORWARD chain policy is DROP by 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!