Container Networking: Bridges, DNS and Why localhost Lies
Why two containers can't reach each other on the default bridge, what -p actually does, and why localhost inside a container isn't your laptop.
One Idea Explains Most Networking Confusion
From the first guide: a container gets its own network namespace — its own interfaces, its own IP address, its own routing table, its own view of ports.
Almost every networking surprise follows directly from that:
- Your API container can't reach the database at
localhost:5432— becauselocalhostinside the API container is the API container. - Two containers both bind port 80 without conflict — because they're different port spaces.
- Your app "starts fine" but you can't open it in a browser — because nothing published the port out of the namespace.
- Containers can't find each other by name — because of which network they're on, covered below.
Keep the namespace in mind and these stop being separate mysteries.
The Network Drivers
- bridge — the default. Containers get a private IP on a virtual switch and reach the outside world through NAT.
- host — the container shares the host's network namespace entirely. No isolation, no port mapping needed, and port conflicts with the host become real.
- none — no networking. Useful for batch jobs that should have no network access at all.
- overlay — spans multiple hosts. This belongs to Docker Swarm; it is not what Kubernetes uses, so treat it as out of scope unless you're running Swarm.
--network host behaves differently depending on where you're running. On Linux it genuinely shares the host's stack. On Docker Desktop (macOS and Windows), containers run inside a Linux VM, so "host" means the VM — not your laptop. A --network host example from a Linux blog post may simply not do what you expect on a Mac.
The Default Bridge Has No DNS
This is the single highest-value fact in this guide.
Containers on the default bridge network can reach each other by IP address — but not by name. Containers on a user-defined bridge network get automatic DNS resolution by container name.
That's why every reasonable tutorial tells you to create a network first:
# No DNS between these — "api" is not resolvable
docker run -d --name db postgres:17
docker run -d --name api myapp # connecting to "db" fails
# With a user-defined network, "db" resolves
docker network create appnet
docker run -d --name db --network appnet postgres:17
docker run -d --name api --network appnet myapp # "db" worksInside api, the hostname db now resolves to the database container's current IP — and keeps working after the database container is recreated with a different IP, which is exactly what hardcoded IPs can't do.
Docker Compose creates a user-defined network for your project automatically and names each container by its service name. That's why Compose examples "just work" with hostnames like db and redis while equivalent raw docker run commands don't. It isn't magic — Compose is doing the docker network create step for you.
Publishing Ports
A container's port is inside its namespace. To reach it from the host you must publish it:
docker run -p 8080:80 nginx
# ^^^^ ^^
# host containerTraffic to localhost:8080 on your machine is forwarded to port 80 inside the container. The order is host-first, and getting it backwards is a common early mistake.
Some variations worth knowing:
docker run -p 8080:80 nginx # all host interfaces — reachable from the network
docker run -p 127.0.0.1:8080:80 nginx # localhost only — much safer default for dev
docker run -P nginx # publish all EXPOSEd ports to random host ports-p 8080:80 binds on all host interfaces, which on a machine with a public IP means the service is exposed to the internet. Database containers published this way — Postgres on 5432, Redis on 6379, Mongo on 27017 — are found and compromised by automated scanners routinely. For local development, prefer -p 127.0.0.1:5432:5432.
EXPOSE documents, it does not publish
EXPOSE 80 in a Dockerfile does not open a port. It's metadata declaring which port the image intends to serve on. It informs -P and tells a human reading the Dockerfile what to expect — nothing more. Publishing always requires -p at run time (or ports: in Compose).
Check yourself
An API container connects to Postgres at localhost:5432 and gets 'connection refused'. Both containers are running and the database is healthy. What's happening?
Binding to 0.0.0.0, Not 127.0.0.1
A subtler failure: you published the port correctly and the browser still can't connect.
Many frameworks default to binding 127.0.0.1 — the loopback interface only. Inside a container that means "reachable only from within this container", so Docker's port forwarding has nothing to forward to.
The fix is in your application's configuration, not Docker's:
app.listen(3000, "0.0.0.0"); // not "127.0.0.1" or "localhost"uvicorn.run(app, host="0.0.0.0", port=8000)Quick diagnosis: if docker logs shows the server started but curl localhost:PORT from the host refuses the connection, check what address the app bound to before suspecting Docker. The log line usually says — Listening on 127.0.0.1:3000 is the smoking gun.
Reaching the Host From a Container
Sometimes a container needs to call a service running on your machine — a database you run natively, or another dev server.
localhost won't do it, for the reason established above. Use the special hostname:
docker run --add-host=host.docker.internal:host-gateway myapphost.docker.internal resolves to the host from inside a container. On Docker Desktop it's available by default; on Linux you add it with --add-host as shown.
Inspecting What You Got
Networking problems are fastest to solve by looking rather than guessing:
docker network ls # which networks exist
docker network inspect appnet # which containers are attached, with IPs
docker port myapp # what's actually published
docker inspect --format '{{json .NetworkSettings.Networks}}' myapp | jq
docker exec -it myapp getent hosts db # does this name resolve from inside?That last one settles name-resolution arguments instantly. If getent hosts db returns nothing from inside the container, the two containers are not on a shared user-defined network — no amount of application configuration will fix that.
Check yourself
Two containers were started with --name web and --name cache, both without specifying a network. From web, `ping cache` fails to resolve. Why?
What's Next
Containers can now talk to each other. The next guide covers making their data outlive them — the difference between volumes and bind mounts, why the choice matters more than it looks, and the mount mistake that silently destroys a database.