DEV Community

Cover image for Docker Networking, Demystified: Bridge, Host, and Container DNS
James Joyner
James Joyner

Posted on

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
Enter fullscreen mode Exit fullscreen mode
NETWORK ID     NAME      DRIVER    SCOPE
0a1b2c3d4e5f   appnet    bridge    local
1122aabbccdd   bridge    bridge    local
99887766ffee   host      host      local
ab12cd34ef56   none      null      local
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now check that app can resolve and reach redis purely by name:

docker exec app getent hosts redis
Enter fullscreen mode Exit fullscreen mode
10.0.0.5         redis
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
PONG
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
(no output — lookup fails)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

1. 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}}'
Enter fullscreen mode Exit fullscreen mode
app redis
Enter fullscreen mode Exit fullscreen mode

If a container you expected isn't listed, that's your bug. Attach a running one with docker network connect appnet <container>.

2. App bound to 127.0.0.1 inside the container. This is the sneakiest one. If your app listens on 127.0.0.1:5000 instead of 0.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"
Enter fullscreen mode Exit fullscreen mode
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       128     127.0.0.1:5000       <-- the problem
Enter fullscreen mode Exit fullscreen mode

The fix is in the app config, not Docker: bind to 0.0.0.0. Flask's --host=0.0.0.0, a HOST=0.0.0.0 env var, server.address=0.0.0.0 — whatever your framework calls it.

3. 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 web
Enter fullscreen mode Exit fullscreen mode
80/tcp -> 0.0.0.0:8080
Enter fullscreen mode Exit fullscreen mode

No 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.

4. Right network, wrong port number. Container-to-container traffic uses the internal port. If Redis listens on 6379, you connect to redis:6379 regardless of any -p flag. 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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)