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.
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 createdThe 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 port — api 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| Driver | Isolation | DNS by name | Port mapping | Use it for |
|---|---|---|---|---|
bridge (user-defined) | Own namespace + IP | Yes (by container name) | -p for outside access | Default for multi-container apps |
bridge (default) | Own namespace + IP | No (IP only) | -p for outside access | Quick one-offs; avoid for app wiring |
host | None (shares host stack) | N/A (host resolver) | None {no -p} | Lowest latency, single port owner |
none | Total (loopback only) | No | No connectivity | Fully sandboxed jobs |
overlay | Own namespace + IP | Yes (Swarm services) | Published per service | Multi-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. Neverlocalhost, 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. (hostnetworking is the other route — thenlocalhostis the host, because there is no separate namespace.) - To reach the container from the host, publish a port with
-pand connect tolocalhost:<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.
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?
A Dockerfile has EXPOSE 80 but the container is started with no -p flag. Can you reach the app from your host browser?
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.
- 01Why 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?
- 02Inside 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?
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.