Your containers start fine. They can reach the internet. But the web app can't talk to the database, the database can't be reached from your laptop, and nobody — not even you — remembers why -p 8080:80 stopped working after a restart. This is the moment every developer meets Docker networking: not as a concept, but as a wall.
Docker networking is not magic. It is a small set of network drivers with clear rules, plus a built-in DNS server that most connectivity bugs come from ignoring. Once you know which driver to pick and how containers find each other, the wall comes down.
1. The Default Bridge: What You Already Run
When you install Docker, it creates a virtual network called bridge (on Linux; Docker Desktop on macOS and Windows wraps it in a lightweight VM). Any container started without --network attaches to it.
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
3a8f6c1b2d4e bridge bridge local
c2d9e0a1b3f5 host host local
7e4a1c9b2d6f none null local
Each container on the bridge gets its own network namespace: a private IP like 172.17.0.2, its own routing table, and its own iptables rules. Outbound traffic is NATed through the host — which is why apt-get works inside a container without any configuration. Inbound traffic only arrives if you explicitly publish a port.
$ docker run -d --name web -p 8080:80 nginx
-p 8080:80 tells Docker to accept connections on the host's port 8080 and forward them to port 80 inside the container. It does not make the container reachable from other containers by its IP — that works already, but only while that IP stays stable.
2. Container-to-Container: DNS Is the Answer
Containers on the default bridge can ping each other by IP, but not by name. This is the trap. Two containers, one running Postgres and one running your API — the API connects to 172.17.0.2, everything works, then you recreate the Postgres container and its IP changes. The API breaks for no obvious reason.
The fix is a user-defined bridge network. Docker runs an embedded DNS server on these networks, and every container is automatically registered by its name (and container ID).
$ docker network create appnet
$ docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:16
$ docker run -d --name api --network appnet -p 3000:3000 my-api
# Inside the api container, this now works:
# psql -h db -U postgres
No IPs, no --link legacy flags, no service discovery tooling. The name db resolves to the Postgres container on the same network, and Docker keeps the mapping fresh across restarts. Rule of thumb: always put containers that talk to each other on a user-defined network, never on the default bridge.
The embedded DNS only works on user-defined networks. The default bridge has no automatic DNS, which is why you never see docker run --network default in production examples.
3. Port Publishing vs. Container IPs
A common confusion: "If containers have IPs, why do I need -p?" Because those IPs exist inside a private, host-local bridge. Traffic from outside the host never routes to 172.17.0.2. Port publishing is the deliberate, firewall-like opening of one hole.
$ docker run -d --name web --network appnet -p 8080:80 nginx
$ docker run -d --name metrics --network appnet prom/prometheus # NOT published
Now web is reachable from your browser at localhost:8080, and Prometheus is reachable from other containers on appnet as http://metrics:9090 — but not from your host. That asymmetry is a feature: it is the container-native way of keeping internal services internal. Only expose what needs exposing.
4. Host Networking: No Isolation, Full Speed
$ docker run -d --name cache --network host redis
With host, the container shares the host's network namespace. No NAT, no bridge hop, no virtual interfaces — the container binds directly to the host's interfaces, and -p is meaningless (and rejected).
This driver buys raw performance and lets the container see host-only services. It costs you everything Docker networking normally guarantees: no per-container IP, no port isolation, no network-level security boundary. A container running with --network host can bind to any port, and any process on the host can see its traffic.
Use it for: latency-sensitive data-plane tools (memcached-style caches, benchmarks), or when a container needs to join a host-only VPN. Avoid it for anything multi-tenant or untrusted.
5. Overlay: Networking Across Hosts
Bridge networks are host-local. If your API runs on host A and the database on host B, a bridge can't connect them. The overlay driver creates a virtual network that spans multiple Docker hosts, encrypting traffic with IPSEC and tunneling packets with VXLAN.
$ docker network create -d overlay --attachable prodnet
$ docker service create --name api --network prodnet my-api
Overlay networks are the native networking layer of Docker Swarm. In Kubernetes you rarely meet them directly — the cluster installs a CNI plugin (Calico, Cilium, Flannel) that implements the same idea: a flat virtual network on top of the physical one, so a pod on any node can reach a pod on any other node by IP.
If you are running a single host, or a multi-host setup already managed by Kubernetes, you will not need overlay. Know that it exists, and know that the DNS + virtual-IP pattern you learned on bridge networks is exactly the abstraction Kubernetes later reuses.
6. The Container Driver: Sidecar Pattern
A fourth driver worth knowing: container. It attaches a new container to another container's network namespace — the same IP, same localhost.
$ docker run -d --name app --network appnet my-api
$ docker run -d --name sidecar --network container:app my-agent
The sidecar sees localhost:8080 and reaches the app. This is how logging, metrics, and proxy sidecars are wired before you hand the problem to Kubernetes' own sidecar pattern. Handy for debugging too: run a throwaway nicolaka/netshoot container on a target's network and get tcpdump, curl, and nslookup without polluting the app image.
$ docker run -it --rm --network container:app nicolaka/netshoot
netshoot # tcpdump -i eth0 port 5432
7. Compose: Networking Without the Pain
Docker Compose wires all this up for you. Every project gets its own network, and every service is reachable by its service name — which is exactly the DNS behavior from section 2, configured for free.
services:
db:
image: postgres:16
api:
build: .
ports:
- "3000:3000"
depends_on:
- db
Inside the api container, db:5432 resolves. If you need to split concerns, define extra networks and attach services to only the ones they need — the same least-privilege idea as unpublished ports.
8. Debugging Connectivity Like a Pro
When containers can't talk, work from the inside out:
# 1. Are both containers on the same network?
$ docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{end}}'
db api
# 2. Does DNS resolve?
$ docker exec api getent hosts db
172.18.0.2 db
# 3. Can the port be reached?
$ docker exec api curl -sv http://db:5432
# 4. Inspect the other side
$ docker logs db
$ docker inspect db --format '{{json .NetworkSettings.Ports}}'
The three questions that solve 90% of Docker networking bugs:
- Same network? Containers on different networks cannot see each other, even on the same host. Attach with
docker network connect appnet db. - Right name? Use the service/container name, not an IP. If you hardcoded an IP, it changed on the last recreate.
- Port actually listening?
Connection refusedmeans the app isn't bound; a timeout means the network path is wrong. Different errors, different fixes.
Summary
Docker networking, distilled:
- Default bridge — fine for standalone containers, useless for service-to-service communication.
- User-defined bridge — automatic DNS by container name. Use it for everything that talks internally.
- Port publishing — the only way inbound traffic arrives. Publish deliberately, keep internal services private.
- Host — performance and host visibility at the cost of isolation.
- Overlay — multi-host networking; the pattern Kubernetes CNI plugins build on.
- Container — share another container's network namespace for sidecars and debugging.
Learn to think in names instead of IPs, pick user-defined networks by default, and debug from the container outward. The wall disappears — and so does that whole category of "it worked yesterday" bugs.