Skip to content
Published on

Docker Network Modes and Connection Problems — bridge, host, overlay, and the 127.0.0.1 Trap

Share
Authors

Introduction — The Container Is Up but Nothing Connects

docker ps says Up. The log even shows Server listening on port 3000. Yet opening it in a browser gets the connection refused. Running curl http://api:3000 from the container next door cannot resolve the name. Trying curl http://127.0.0.1:5432 inside the container to reach the database on the host reports that there is nothing there.

None of these three symptoms is an application bug. They all follow from a single fact: a container has its own network namespace. A network namespace replicates not only interfaces and routing tables but the loopback address and the port number space along with them. A container's 127.0.0.1 is a completely different address from the host's 127.0.0.1.

This article first lays out how each mode behaves, then dissects each of those three symptoms on top of that.

What the Five Modes Actually Do

docker run --network accepts five kinds of values. Memorizing the names alone gets confusing, so it is better to understand each one by how it treats the network namespace.

bridge is the default. It creates a new namespace per container, puts one end of a veth pair into the container, and attaches the other end to a bridge on the host.

docker run -d --name web nginx:1.27
ip -br link show type bridge
ip -br link | grep veth
docker0          UP             02:42:8f:1c:03:aa <BROADCAST,MULTICAST,UP,LOWER_UP>
veth3a91c7e@if12 UP             ba:19:7c:2e:44:81 <BROADCAST,MULTICAST,UP,LOWER_UP>
docker exec web ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if13        UP             172.17.0.2/16

host does not create a namespace at all and uses the host's stack as is. With no NAT the overhead disappears, but port conflicts appear exactly as they would, and the -p option is ignored. It is only meaningful on Linux, and behaves differently on Docker Desktop.

docker run --rm --network host alpine:3.20 ip -br addr | head -3
lo               UNKNOWN        127.0.0.1/8
enp5s0           UP             10.0.4.17/24
docker0          UP             172.17.0.1/16

none creates a namespace but leaves only loopback in it. Use it for batch jobs that need no network, or for isolated execution environments.

container:NAME joins another container's namespace. The two containers share the same interface and the same loopback, so they can call each other at 127.0.0.1.

docker run -d --name app myapp:v1
docker run -d --name sidecar --network container:app envoyproxy/envoy:v1.31-latest
docker exec sidecar ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if17        UP             172.17.0.4/16

A Kubernetes pod is exactly this structure. The pause container owns the namespace and the rest join it, which is why containers in the same pod can communicate over loopback.

overlay is a virtual L2 network spanning multiple hosts. It encapsulates packets with VXLAN to cross between nodes, and requires Swarm or an external key-value store.

ModeNamespaceContainer IPName resolutionPort publishingMain use
Default bridgeNewly created172.17.0.0/16Not availableRequiredStandalone container
User-defined bridgeNewly createdPer-network subnetEmbedded DNSRequiredMultiple services on one host
hostShares the hostHost IPHost configurationIgnoredHigh performance, port scanners
noneNewly createdLoopback onlyNoneNot possibleFull isolation
container:NAMEShared with targetSame as targetSame as targetOwned by targetSidecar
overlayNewly createdOverlay subnetEmbedded DNS + VIPRouting meshMulti-host

Why Containers Cannot Find Each Other by Name on the Default bridge

This is the most common first frustration.

docker run -d --name db postgres:16
docker run --rm alpine:3.20 ping -c1 db
ping: bad address 'db'

The two containers are on the same docker0 bridge and can talk over IP. The only thing that fails is name resolution. That is because Docker's embedded DNS is not attached to the default bridge network. In the past you would fix it with --link, which added entries to /etc/hosts, but that feature was designated legacy long ago.

Create a user-defined network and the situation changes.

docker network create app-net
docker run -d --name db --network app-net postgres:16
docker run --rm --network app-net alpine:3.20 ping -c1 db
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.089 ms

The difference is plain in the resolver configuration inside the container.

docker run --rm --network app-net alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

127.0.0.11 is an address that exists only inside the container namespace, and that namespace's NAT rules hand the request to the Docker daemon's resolver. The daemon knows the container names and aliases belonging to the same network, so it returns an answer, and passes names it does not know on to the host's upstream DNS.

You can attach several aliases too. That is useful for keeping the old name alive while renaming a service.

docker run -d --name api-v2 \
  --network app-net \
  --network-alias api \
  --network-alias backend \
  api:v312

With Compose, all of this is automatic. Compose creates a user-defined network per project and registers service names as aliases. That is why something that worked under Compose can stop working once you move it to docker run.

services:
  api:
    image: api:v312
    environment:
      DATABASE_URL: postgres://app@db:5432/app   # 'db' is the service name
  db:
    image: postgres:16

The conclusion is simple. The default bridge exists for backward compatibility; in practice you always create and use a user-defined network.

What Port Publishing Really Is — iptables DNAT and the Binding Address

What -p 8080:80 does is not magic but a single NAT rule.

docker run -d --name web -p 8080:80 nginx:1.27
sudo iptables -t nat -S DOCKER
-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

The destination of a TCP packet arriving at the host's 8080 is changed to 172.17.0.2:80. On top of that, Docker also starts a userspace process called docker-proxy.

ps -eo pid,args | grep docker-proxy | head -1
  52104 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80

This process handles paths where the NAT rules do not apply, such as loopback traffic originating from the host itself. Most traffic is handled by iptables and docker-proxy plays a supporting role.

-host-ip 0.0.0.0 should catch your eye. Default publishing binds to every interface on the host. Internal network or public IP, it all opens. Cases of a local development database left open like this and exposed to the internet still keep appearing.

# Dangerous: exposed on every interface
docker run -d -p 5432:5432 postgres:16

# Safe: reachable only from the host loopback
docker run -d -p 127.0.0.1:5432:5432 postgres:16
ss -tlnp | grep -E '5432'
LISTEN 0  4096  127.0.0.1:5432  0.0.0.0:*  users:(("docker-proxy",pid=52310,fd=4))

On a remote development server, layering SSH forwarding on top is the standard practice.

ssh -N -L 5432:127.0.0.1:5432 dev@build-01.internal

A service that only needs to talk to other containers should not be published at all. On the same user-defined network they can connect by name, so -p is unnecessary. Compose's expose is documentation only and does not open a port on the host.

127.0.0.1 Inside a Container Is Not the Host

This is the point beginners stumble over most. When developing locally with the database on the host and only the application in a container, the familiar connection string fails outright.

docker run --rm --network app-net api:v312 \
  sh -c 'nc -zv 127.0.0.1 5432'
nc: 127.0.0.1 (127.0.0.1:5432): Connection refused

A container's loopback is the container itself. To reach the host you need a different address that points at the host.

On Docker Desktop a special name is already provided.

docker run --rm alpine:3.20 \
  sh -c 'apk add -q bind-tools && dig +short host.docker.internal'
192.168.65.254

On Linux it is not provided automatically, so you add it explicitly.

docker run --rm --add-host=host.docker.internal:host-gateway \
  alpine:3.20 getent hosts host.docker.internal
172.17.0.1        host.docker.internal

host-gateway is a reserved word that Docker substitutes with the bridge's gateway address. In Compose you use it the same way.

services:
  api:
    image: api:v312
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      DATABASE_URL: postgres://app@host.docker.internal:5432/app

There is one more thing to check. If the service on the host is bound only to 127.0.0.1, it cannot be reached at the bridge gateway address. With PostgreSQL you would adjust listen_addresses, or edit pg_hba.conf to allow only the container's subnet. The moment you open a host service on 0.0.0.0, a firewall review becomes necessary alongside it.

When the Application Binds Only to Loopback

This is the second most common symptom. The container is up, the port is published, and the connection is refused.

docker run -d --name api -p 3000:3000 api:v312
curl -sS -m 3 http://localhost:3000/healthz
curl: (52) Empty reply from server

Check the actual listening address from inside the container.

docker exec api sh -c 'ss -tlnp'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       511          127.0.0.1:3000        0.0.0.0:*      users:(("node",pid=1,fd=20))

The application bound only to the container's loopback. Packets coming in from the host arrive on eth0, that is 172.17.0.2, so they never reach this socket. The correct value is 0.0.0.0.

LISTEN  0  511  0.0.0.0:3000  0.0.0.0:*  users:(("node",pid=1,fd=20))

Defaults differ per framework. Many tools default to loopback for development convenience, so this always bites when moving to containers.

# Node / Express
node -e "require('express')().listen(3000, '0.0.0.0')"

# Flask
flask run --host=0.0.0.0 --port=5000

# Django
python manage.py runserver 0.0.0.0:8000

# Rails
bin/rails server -b 0.0.0.0 -p 3000

# Vite
vite --host 0.0.0.0

# Next.js
next start -H 0.0.0.0 -p 3000

# Go: net/http
# http.ListenAndServe(":8080", nil)  <- already every interface

The worry that frequently comes up here is "is it not dangerous to open on 0.0.0.0?" Inside a container, 0.0.0.0 merely means that container's entire network namespace, and whether it is externally exposed is determined by the publishing binding address from the previous section. The application binds to 0.0.0.0 inside the container, and the exposure scope is controlled by the binding address on the host side. Mix the two layers and you get both wrong.

Firewalls, MTU, Overlay — What You Meet in Production

There are three kinds of problems that never appear locally and only appear on servers.

First, the host firewall fails to block Docker ports. You blocked 8080 with UFW, and yet it is reachable from outside.

sudo ufw status | head -4
Status: active
To                         Action      From
--                         ------      ----
8080/tcp                   DENY        Anywhere
curl -sS -o /dev/null -w '%{http_code}\n' http://203.0.113.42:8080/
200

The reason is where in the chains it sits. UFW's rules mostly go into the INPUT chain. But a packet headed for a container does not have the host as its final destination, so it passes through the FORWARD chain rather than INPUT, and before that its destination has already been rewritten by DNAT in the nat table's PREROUTING. UFW never gets a chance to inspect it.

There are three remedies. The safest and simplest is the binding-address restriction shown earlier. When external exposure is required, put rules directly into the DOCKER-USER chain that Docker leaves empty for user rules.

sudo iptables -I DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp --dport 8080 -j DROP
sudo iptables -t filter -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp -m tcp --dport 8080 -j DROP
-A DOCKER-USER -j RETURN

DOCKER-USER survives a Docker restart and is evaluated before Docker's auto-generated rules. The third option, iptables: false, stops Docker from creating NAT rules at all but forces you to configure container outbound traffic yourself, so it is not recommended. Recent Docker versions have been strengthening default filtering, so you must always verify the actual behavior on the version you are running.

Second, MTU problems. The symptom is very distinctive. The TCP connection is established and small requests succeed, but large responses or the TLS handshake stall.

docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/ping
200
docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/bulk
curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received

The cause is encapsulation. An overlay network spends an extra 50 bytes on the VXLAN header, so the actually usable MTU drops to 1450. Layer IPsec or WireGuard on top and it shrinks further. But if the container interface is still 1500, large packets need to be fragmented, and if ICMP is blocked along the path, the sender cannot learn about it and simply stalls. This is the black-hole flavor of the MTU problem.

Diagnose it with a ping carrying the do-not-fragment flag.

docker exec api ping -M do -s 1472 -c 2 api.partner.example.com
PING api.partner.example.com (203.0.113.90) 1472(1500) bytes of data.
ping: local error: message too long, mtu=1450
docker exec api ping -M do -s 1422 -c 2 api.partner.example.com
1430 bytes from 203.0.113.90: icmp_seq=1 ttl=52 time=11.8 ms

1422 bytes gets through and 1472 does not, so the real MTU is 1450. Specifying the MTU when creating the network fixes it.

docker network create \
  --driver bridge \
  --opt com.docker.network.driver.mtu=1450 \
  app-net
// /etc/docker/daemon.json — applies to the default bridge
{
  "mtu": 1450
}

Third, the requirements of the overlay network itself. A Swarm overlay needs three kinds of communication open between nodes: 2377/tcp for cluster management, 7946/tcp and 7946/udp for gossip between nodes, and 4789/udp for the VXLAN data plane. If just 4789/udp is blocked in a security group, you end up in the confusing state where service discovery works but the actual traffic does not flow.

docker network create --driver overlay --attachable --opt encrypted app-mesh
docker service create --name api --network app-mesh --replicas 3 api:v312
docker network inspect app-mesh --format '{{json .Containers}}' | jq 'keys | length'
3

Turning on --opt encrypted encrypts inter-node traffic with IPsec, but the added headers shrink the effective MTU again. You have to redo the MTU arithmetic above.

The order for narrowing down where the problem is never changes. Look at the listening address inside the container, connect from another container on the same network by name and by IP separately, connect to the published port from the host, and finally try from outside. Whichever step breaks is the cause.

docker exec api ss -tlnp                                  # 1. listening address
docker run --rm --network app-net alpine:3.20 \
  sh -c 'nc -zv api 3000; nc -zv 172.19.0.3 3000'         # 2. name/IP resolution
curl -sS -m 3 http://127.0.0.1:3000/healthz               # 3. from the host
curl -sS -m 3 http://203.0.113.42:3000/healthz            # 4. from outside

Closing — A Connection Problem Is a Question of Which of the Four Steps Broke

There is one sentence to remember about Docker networking. Because a container has its own network stack, an address must always be read with a note of "from whose point of view" attached. The container's 127.0.0.1, the host's 127.0.0.1, the bridge gateway, and the host IP as seen from outside are all different things.

The practical defaults are short too. Create a user-defined network to get name resolution, have the application bind to 0.0.0.0 inside the container, control external exposure only through the binding address on the host side, and do not publish ports used solely between containers. And never, ever assume that the host firewall will block Docker ports for you.