open atlas
↑ Back to track
Deployment & Infra DEP · 09 · 01

Container networking: bridges, DNS by name, and the localhost trap

Containers talk over network drivers — bridge by default. A user-defined bridge adds DNS by container name; the default bridge does not. Publish ports only for outside access, and never trust localhost inside a container.

DEP Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

Two containers, api and db, both running, both healthy. The api logs ECONNREFUSED 127.0.0.1:5432 on every request. The developer swears the database is up — docker ps proves it — and burns an afternoon checking Postgres config, firewalls, and credentials. The bug is none of those. The connection string says localhost, and inside the api container localhost means the api container itself, where nothing listens on 5432. The database is one network hop away, reachable by the name db — but only if the two containers share the right kind of network.

In ten minutes you will know exactly why localhost lies to you inside a container, which driver to reach for, and how to wire services so they never lose each other across a restart.

Drivers: how a container gets a network at all

A container starts in its own network namespace — its own interfaces, routing table, and localhost. The network driver decides how that namespace connects to anything else. You pick the driver when you create the network or run the container.

The bridge driver is the default. Docker creates a private virtual switch on the host (docker0 for the default network) and connects each container to it with a virtual ethernet pair. Containers get internal IPs on a private subnet (often 172.17.0.0/16), talk to each other over that bridge, and reach the outside world through NAT — the host masquerades their traffic behind its own IP. From outside the host, those container IPs are invisible until you publish a port.

The host driver removes the isolation entirely: the container shares the host’s network namespace. There is no separate container IP, no NAT, and no port mapping — if the app binds :8080, it is on the host’s :8080 directly. That is the fastest option (no bridge, no NAT hop) and the reason to use it for latency-sensitive or high-throughput workloads, but you give up isolation and you cannot run two containers that both want the same port.

The none driver gives the container a namespace with only loopback — no external connectivity at all, for fully sandboxed work. overlay spans multiple hosts (Swarm services, multi-host clusters) by tunnelling container traffic between daemons. macvlan hands the container its own MAC address so it appears as a physical device on the LAN, for legacy systems that expect that. The everyday workhorse is bridge; the rest are situational.

docker run -d --name web nginx                 # default bridge
docker run -d --network host nginx             # share the host's stack, no -p needed
docker run -d --network none alpine sleep 1d   # loopback only, isolated
docker network ls                              # bridge, host, none + any you created

The default bridge vs a user-defined bridge

Here is the senior point that the hook turns on. There are two kinds of bridge network, and they are not equivalent.

The default bridge (the one named bridge that every container joins unless told otherwise) has no built-in DNS. Containers on it can reach each other only by raw IP address — which is fragile, because IPs are assigned dynamically and change across restarts. The old workaround was the deprecated --link flag.

A user-defined bridge — any network you create yourself — adds an embedded DNS server. Containers on it resolve each other by container name automatically. Start db and api on the same user-defined network and api can connect to db:5432 with no IP, no link, no config. This is service discovery, and it is the single reason to always put related containers on a network you created rather than the default bridge.

docker network create appnet                              # user-defined bridge
docker run -d --name db  --network appnet postgres:16
docker run -d --name api --network appnet my/api          # api reaches db by the name "db"

This is exactly what docker compose does for you: every Compose project gets its own user-defined bridge, so services reach each other by their service name out of the box. You rarely create the network by hand because Compose already did.

Why this works

On a user-defined bridge, name resolution is dynamic: if db restarts and gets a new IP, the embedded DNS returns the new address on the next lookup, so api keeps working without a restart. The default bridge has no such DNS, which is why hardcoding a container IP there breaks on the next restart. Docker’s DNS also forwards names it does not know to the host’s resolver, so external hostnames still resolve normally from inside the container.

Publishing ports vs talking inside the network

Two containers on the same user-defined network talk directly on the container’s own portapi hits db:5432 whether or not 5432 is ever published. Publishing is only about reaching a container from outside the Docker network, typically from the host or the internet.

-p host:container (--publish) maps a host port onto a container port through the bridge’s NAT. -p 8080:80 means “send the host’s :8080 to this container’s :80.” You publish the public-facing service (the web server, the API gateway) and leave internal services like the database unpublished — they do not need a host port because their clients are on the same network.

A common confusion: EXPOSE in a Dockerfile does not publish anything. It is documentation — metadata declaring which port the app listens on. It opens no host port and changes no firewall. Only -p (or Compose’s ports:) actually publishes; EXPOSE just tells readers and tools the intent.

docker run -d --network appnet -p 8080:80 --name web my/web   # host:8080 → container:80
# db has NO -p: it is unreachable from the host, but web reaches it at db:5432 internally
DriverIsolationDNS by namePort mappingUse it for
bridge (user-defined)Own namespace + IPYes (by container name)-p for outside accessDefault for multi-container apps
bridge (default)Own namespace + IPNo (IP only)-p for outside accessQuick one-offs; avoid for app wiring
hostNone (shares host stack)N/A (host resolver)None {no -p}Lowest latency, single port owner
noneTotal (loopback only)NoNo connectivityFully sandboxed jobs
overlayOwn namespace + IPYes (Swarm services)Published per serviceMulti-host / Swarm clusters

The localhost trap

Now the hook resolves. Inside a container, localhost (127.0.0.1) is the container’s own loopback — not the host, and not any other container. Each container has its own loopback interface in its own namespace. So api connecting to localhost:5432 is asking itself for Postgres, finds nothing listening, and gets ECONNREFUSED.

The fixes follow from everything above:

  • To reach another container, use its name on a shared user-defined network: db:5432. Never localhost, never a hardcoded IP.
  • To reach a service on the host machine from inside a container (a database running natively on your laptop, say), use host.docker.internal, which Docker resolves to the host’s address. (host networking is the other route — then localhost is the host, because there is no separate namespace.)
  • To reach the container from the host, publish a port with -p and connect to localhost:<hostPort> on the host.

Together these three rules mean one thing: localhost only means “this same container,” so every cross-container address must be an explicit name or special hostname — without that discipline, your first production restart breaks the wiring silently. The instant you cross a container boundary, you need a name — db, api, or host.docker.internal.

Quiz

The api container connects to localhost:5432 to reach the db container, both on the same user-defined network, and gets ECONNREFUSED. What is wrong?

Quiz

A Dockerfile has EXPOSE 80 but the container is started with no -p flag. Can you reach the app from your host browser?

Pick the best fit

A multi-container app (web + api + db) runs on one host and the web tier must be reachable from the internet. Pick the networking setup.

Recall before you leave
  1. 01
    Why does a user-defined bridge let containers reach each other by name when the default bridge does not, and what does that change in practice?
  2. 02
    Inside a container, what does localhost mean, and how do you reach (a) another container, (b) a service on the host machine, and (c) the container from the host?
Recap

A container starts in its own network namespace, and the network driver decides how it connects. Bridge is the default: a private virtual switch on the host gives each container an internal IP and NATs its outbound traffic, while host networking shares the host’s stack with no isolation and no port mapping, none gives loopback only, and overlay and macvlan cover multi-host and LAN-presence cases. The senior distinction is between the default bridge, which has no DNS and forces fragile IP addressing, and a user-defined bridge, whose embedded DNS lets containers resolve each other by container name — automatic service discovery, and the reason docker compose creates a network per project so services reach each other by name. Publishing with -p maps a host port to a container port and is needed only for access from outside the Docker network; containers on the same user-defined network already talk on the container port directly, and EXPOSE is mere documentation that publishes nothing. Finally, localhost inside a container is that container’s own loopback, so reach a peer by name (db:5432), the host by host.docker.internal, and the container from the host via a published port — never assume localhost crosses a container boundary. Now when you see ECONNREFUSED pointing at 127.0.0.1, your first question should be: which container namespace is the app actually running in?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.