Docker Networking, Demystified: Bridge, Host, and Container DNS
Most people meet Docker networking the same way I did: two containers that should be able to talk to each other, and one of them stubbornly refusing to connect. You did nothing wrong on the application side, the code works locally, and yet curl inside one container gives you connection refused reaching the other.
The good news is that Docker networking is small once you understand three or four moving parts, and almost every real-world problem comes down to the same handful of causes. I want to walk through how containers actually find each other and the outside world, with commands you can paste and run. No magic, just the model.
The pieces: bridges, DNS, and published ports
Every container attaches to one or more networks. On Linux, the default network is a bridge - a virtual switch (docker0) that hands each container a private IP and NATs its outbound traffic to the host. Containers on the same bridge can reach each other by IP; the host reaches them only if you publish a port.
There are three network modes worth knowing well: the default bridge, a user-defined bridge, and host. There's also none for full isolation. The single most useful thing I can tell you is: don't use the default bridge for anything real. Create your own.
Default bridge vs. user-defined bridge
Here's the difference that trips everyone up. On the default bridge, containers can talk by IP but there is no automatic name resolution. If you want app to reach db by name, you'd have to use the legacy --link flag, which is deprecated and awkward.
On a user-defined bridge, Docker runs an embedded DNS server, and every container is resolvable by its name (and by --network-alias if you set one). That one feature - automatic DNS by container name - is why you almost always want a user-defined network. You also get better isolation: only containers you explicitly attach can see each other.
Create one and look at it:
docker network create appnet
docker network ls
NETWORK ID NAME DRIVER SCOPE
0a1b2c3d4e5f appnet bridge local
1122aabbccdd bridge bridge local
99887766ffee host host local
ab12cd34ef56 none null local
appnet is a bridge just like the default one, but with DNS turned on for its members.
A worked example: an app talking to Redis by name
Let me make this concrete with two containers on appnet - a Redis instance and a small client - resolving each other by name.
# Redis, named so DNS has something to resolve
docker run -d --name redis --network appnet redis:7.2-alpine
# An app container on the same network
docker run -d --name app --network appnet alpine:3.19 sleep infinity
Now check that app can resolve and reach redis purely by name:
docker exec app getent hosts redis
10.0.0.5 redis
That IP (10.0.0.5 here, yours will differ) came from Docker's embedded DNS, not from anything you configured. Test the actual TCP path:
docker exec app sh -c "apk add --no-cache redis >/dev/null && redis-cli -h redis ping"
PONG
No IPs hardcoded, no --link, no /etc/hosts editing. The name redis resolves because both containers share a user-defined network. Restart Redis and it may get a new IP, but the name keeps working - which is exactly why you reference services by name and never by IP.
For contrast, run the same thing on the default bridge and the name lookup fails:
docker run -d --name redis2 redis:7.2-alpine
docker run --rm alpine:3.19 getent hosts redis2 # no --network, so default bridge
(no output - lookup fails)
Same image, same host, no DNS. That's the whole argument for user-defined networks in one command.
Publishing ports: EXPOSE is not publish
This is the other big source of confusion. Two different concepts get used interchangeably and they are not the same thing.
EXPOSE 80 in a Dockerfile is documentation. It records that the app listens on port 80 inside the container. It does not open anything to the host. You can EXPOSE a port and still be unable to reach it from your laptop.
Publishing with -p is what actually maps a host port to a container port:
docker run -d --name web -p 8080:80 nginx:1.27-alpine
The syntax is -p HOST:CONTAINER. So 8080:80 means "traffic to the host's port 8080 goes to the container's port 80." Now you can hit it:
curl -s http://localhost:8080 | head -n 1
<!DOCTYPE html>
A subtlety worth knowing: by default -p 8080:80 binds to all host interfaces. If you only want it reachable from the host itself, bind to loopback explicitly:
docker run -d -p 127.0.0.1:8080:80 nginx:1.27-alpine
Two things that surprise people. First, containers on the same user-defined network reach each other on the container's internal port (80 in these examples) with no -p at all - publishing is only for host-to-container traffic. Second, "it works inside the container but I can't reach it from outside" almost always means you never published the port, or you published a different one than the app listens on.
host and none modes
--network host removes network isolation entirely: the container shares the host's network stack. No separate IP, no NAT, and -p is ignored because there's nothing to map - the container's port 80 is the host's port 80.
docker run -d --network host nginx:1.27-alpine
# nginx is now on the host's :80 directly, no -p needed
I reach for host mode in two situations: performance-sensitive workloads where NAT overhead matters, and tools that need to see the host's real interfaces (some monitoring agents, or anything doing raw/multicast networking). The tradeoffs are real, though - you lose port-mapping flexibility, you can collide with ports already in use on the host, and it's Linux-only in the way people expect (on Docker Desktop the semantics differ). Use it deliberately, not as a shortcut around a publish problem.
--network none gives the container a loopback interface and nothing else - no external connectivity at all. It's for batch jobs that process local data and should never touch the network, or as a security boundary. Handy, rarely needed.
Troubleshooting: the four usual suspects
When a connection fails, I check these in order. Almost every case is one of them.
Wrong network. The two containers aren't actually on the same network, so DNS never resolves. Confirm who's attached:
docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{end}}'app redisIf a container you expected isn't listed, that's your bug. Attach a running one with
docker network connect appnet <container>.App bound to 127.0.0.1 inside the container. This is the sneakiest one. If your app listens on
127.0.0.1:5000instead of0.0.0.0:5000, it only accepts connections from inside its own container - not from other containers, not from a published port. You'll get connection refused even though the process is clearly running. Check what it's actually bound to:docker exec app sh -c "netstat -tlnp 2>/dev/null || ss -tlnp"State Recv-Q Send-Q Local Address:Port LISTEN 0 128 127.0.0.1:5000 <-- the problemThe fix is in the app config, not Docker: bind to
0.0.0.0. Flask's--host=0.0.0.0, aHOST=0.0.0.0env var,server.address=0.0.0.0- whatever your framework calls it.Port not published (or the wrong one). You're trying to reach the container from the host but never mapped a port, or mapped one the app doesn't listen on. Check the mapping:
docker port web80/tcp -> 0.0.0.0:8080No output means nothing is published. A mapping to a port your app isn't listening on means you'll connect to the host port and get refused at the container.
Right network, wrong port number. Container-to-container traffic uses the internal port. If Redis listens on 6379, you connect to
redis:6379regardless of any-pflag. Published ports are irrelevant between containers on the same network.
docker network inspect and docker exec ... getent hosts <name> resolve most of these in under a minute. If the name resolves and the port is right, the problem is inside the app.
Compose does the network for you
Everything above is what Docker Compose automates. When you docker compose up, Compose creates a user-defined bridge for the project and attaches every service to it, so services resolve each other by service name out of the box.
services:
app:
build: .
ports:
- "8080:5000" # host:container, published to your machine
environment:
REDIS_URL: "redis://redis:6379" # "redis" is the service name
redis:
image: redis:7.2-alpine
app reaches Redis at the hostname redis because Compose put both on the same network and named the DNS entry after the service. It's the exact same embedded-DNS mechanism from the manual example - you just don't type the docker network create. Note the ports entry is only needed for the service you want to reach from your host; app and redis talk internally with no published port.
If you want a set of worked reference setups to copy from - user-defined networks, published ports, and Compose files already wired up - I keep a collection of Docker stack patterns at devopsaitoolkit.com/stacks/docker that mirror the examples here.
Wrapping up
If you remember one thing: put your containers on a user-defined network and reference them by name, never by IP. That single habit gives you working DNS, clean isolation, and containers that keep talking to each other even as IPs churn underneath them - and it turns most "connection refused" mysteries into a quick check of network membership, bind address, and published port.
Comments
No comments yet. Start the discussion.