Docker Networking Explained
How Docker networking really works: the default bridge, host and none modes, user-defined networks with DNS by container name, publishing ports, and a debugging workflow.
Docker networking trips people up because two different problems get tangled together: how containers talk to each other, and how the outside world talks to a container. They are not the same thing, and the rules are different. Once you separate them - "container to container" lives on Docker networks, "host to container" needs a published port - most of the confusion disappears. This guide builds that mental model, walks through the network types Docker gives you, and ends with the debugging workflow you reach for when something cannot connect.
The one idea: containers live on networks
Every container is attached to at least one Docker network. A network is a virtual switch: containers on the same network can reach each other, and containers on different networks cannot, unless you deliberately connect them. That is the whole model. Almost every connectivity question - "why can't my app reach the database?", "why can't I hit it from my laptop?" - comes down to figuring out which network each side is on and whether a path exists between them.
Two boundaries matter, and keeping them separate is the key insight:
- Container to container is decided by the Docker network. Put two containers on the same network and they can talk. This is internal to Docker and does not involve your host's ports at all.
- Host to container is decided by published ports. A container's ports are private by default. To reach a container from the host (or from anything outside the host), you have to publish a port with
-p, which pokes a hole through the host boundary.
Hold onto that split. Most "it works between containers but not from my browser" problems are a missing published port, and most "the port is published but containers still can't talk" problems are containers on different networks.
The default bridge network
When you install Docker you get a network called bridge (the driver is also called bridge, which is confusing but harmless). Any container you start without asking for a specific network lands here.
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# a1b2c3d4e5f6 bridge bridge local
# 1234567890ab host host local
# fedcba098765 none null local
Containers on the default bridge each get a private IP (typically in the 172.17.0.0/16 range) and can reach each other by IP. But the default bridge has one big limitation: no automatic DNS. Containers cannot find each other by name. If you want container A to reach container B on the default bridge, you need B's IP address, which changes every time it restarts. That is fragile, and it is the main reason you should not build anything real on the default bridge.
docker run -d --name web nginx
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
# 172.17.0.2
The default bridge is fine for a quick one-off container. For anything with more than one container that needs to talk, use a user-defined bridge instead (below). Docker's own docs recommend this, and the reason is exactly the missing DNS.
Host and none networks
Two special network modes sit alongside bridge, and each removes something rather than adding it.
Host networking (--network host) removes the isolation. The container shares the host's network stack directly: no separate container IP, no port mapping. If the container listens on port 8080, it is on the host's port 8080 immediately, no -p needed. This is faster (no NAT layer) and occasionally necessary, but you lose isolation and you can get port clashes with the host or other containers. It also behaves differently on Docker Desktop for Mac and Windows, where the "host" is a Linux VM rather than your machine, so treat it as a Linux-server tool.
docker run -d --network host nginx
# nginx now answers on the host's port 80 directly - no -p flag
curl http://localhost:80
None networking (--network none) removes networking entirely. The container gets only a loopback interface and cannot reach anything, and nothing can reach it. Use it for tasks that should be sealed off - a batch job that only touches mounted files, or a security-sensitive process you never want on the network.
docker run --rm --network none alpine ip addr
# only shows lo (loopback); no eth0
Most of the time you want neither of these. The default and the right answer for multi-container setups is a user-defined bridge.
User-defined bridge networks and DNS
This is the network type you should actually use. Create one, attach your containers to it, and you get the one thing the default bridge lacks: automatic DNS resolution by container name.
docker network create appnet
docker run -d --name db --network appnet postgres
docker run -d --name web --network appnet nginx
Now web can reach db at the hostname db, and db can reach web at web. Docker runs an embedded DNS server that resolves container names (and any --network-alias you set) to their current IPs on that network. The IP can change on restart and it does not matter, because you connect by name.
docker exec web ping -c1 db
# PING db (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.089 ms
This name-based addressing is what makes docker-compose feel like magic: Compose creates a user-defined bridge for your project and names each container after its service, so depends_on services reach each other by service name with zero configuration. You get the same result by hand with docker network create plus --network.
A few properties worth knowing:
- Containers on a user-defined bridge are isolated from other networks by default.
appnetcontainers cannot see containers on some other networkotherednetunless you connect them. - You can attach a container to multiple networks (a reverse proxy on a public-facing network and an internal one, for example). It gets one IP per network.
- The names are scoped to the network. Two different user-defined networks can each have a container aliased
dbwithout collision.
Publishing ports: crossing the host boundary
Everything so far is container-to-container, all inside Docker. To reach a container from your host or from the internet, you publish a port with -p host_port:container_port.
docker run -d --name web -p 8080:80 nginx
# host's 8080 -> container's 80
curl http://localhost:8080
Read -p 8080:80 as "map the host's port 8080 to the container's port 80." The host port is the door you knock on from outside; the container port is where the process inside is actually listening. They do not have to match, and often will not (-p 8080:80 lets you run several web containers on the host by giving each a different host port).
Key rules that clear up most port confusion:
- Without
-p, a container's ports are unreachable from the host. The process is listening, other containers on the same network can reach it, but nothing outside Docker can. Publishing is opt-in. - The host port must be free. If host port 8080 is already taken (by another container's publish, or by a process on your machine), the run fails with "port is already allocated." Pick a different host port.
- Publishing is about the host boundary, not container-to-container. Two containers on the same network talk over the container port directly (port 80 above), no publish needed. You only publish the ports that outside clients need.
- Bind address matters for security.
-p 8080:80listens on all host interfaces (0.0.0.0), so it is reachable from the network.-p 127.0.0.1:8080:80binds only to localhost, which is what you want for something that should not be exposed beyond the host.
docker run -d -p 127.0.0.1:5432:5432 --name db postgres
# database reachable from the host itself, but not from the outside network
Connecting containers so they can talk
To let two containers communicate, put them on the same network. That is the entire answer. The two common ways:
Start them on the same network from the beginning:
docker network create appnet
docker run -d --name db --network appnet postgres
docker run -d --name api --network appnet myapp # api reaches db at hostname "db"
Or attach an already-running container to a network with docker network connect:
docker network connect appnet existing-container
# existing-container is now on appnet too and can reach its other members by name
docker network connect gives a container an additional interface on that network without restarting it, which is how you wire a running container into a new set of peers, or put a proxy on both a frontend and a backend network. To detach, use docker network disconnect appnet existing-container.
The mental check when two containers cannot talk is always the same: run docker network inspect <network> and confirm both containers appear in its Containers list. If one is missing, it is on a different network, and that is your bug.
The network commands you actually use
A small set of docker network subcommands covers day-to-day work.
# List all networks and their drivers
docker network ls
# Create a user-defined bridge (optionally with a fixed subnet)
docker network create appnet
docker network create --subnet 10.5.0.0/24 appnet
# Inspect a network: subnet, gateway, and WHICH containers are attached
docker network inspect appnet
# Attach / detach a running container
docker network connect appnet mycontainer
docker network disconnect appnet mycontainer
# Delete a network (must have no attached containers)
docker network rm appnet
# Clean up all unused networks
docker network prune
docker network inspect is the one you will lean on hardest when debugging. Its Containers section lists every container on the network with its IP - a direct answer to "are these two things actually on the same network?" You can also inspect from the container's side to see all networks it belongs to:
docker inspect -f '{{json .NetworkSettings.Networks}}' mycontainer | tr ',' '\n'
Debugging connectivity problems
Almost every Docker networking failure is one of two shapes: a container cannot reach another container, or a port is not reachable from the host. Work them in that order, because they have different fixes.
Container cannot reach another container
Symptom: your app logs "connection refused" or "no such host" when trying to reach db or another service.
# 1. Are both containers on the same network?
docker network inspect appnet # look for BOTH names in "Containers"
# 2. Can DNS resolve the target name? (fails on the DEFAULT bridge - no DNS there)
docker exec api ping -c1 db
docker exec api getent hosts db # if empty, name resolution is the problem
# 3. Can it reach the port, once the name resolves?
docker exec api wget -qO- http://db:5432 || echo "port not answering"
# 4. Is the target actually listening, and on the right interface?
docker exec db ss -tlnp # is it bound to 0.0.0.0 or only 127.0.0.1?
The usual causes, in order of likelihood:
- Different networks. The two containers are not on the same network, so there is no path.
docker network connectthe stray one, or start them together. This is the number-one cause. - On the default bridge, so no DNS. Name resolution fails because the default bridge has none. Move both to a user-defined network.
- Wrong port. The client connects to a port the server is not listening on. Remember you use the container port here (the internal one), not any published host port.
- Server bound to localhost inside the container. A process listening on
127.0.0.1inside the container is unreachable from other containers even on the same network - it must listen on0.0.0.0.ss -tlnpreveals this.
Port not reachable from the host
Symptom: curl http://localhost:8080 from your machine hangs or returns "connection refused," even though the container is running.
# 1. Is the port actually published? Look at the PORTS column.
docker ps
# ... 0.0.0.0:8080->80/tcp web <- published
# ... 80/tcp web <- NOT published, unreachable from host
# 2. Is the app inside actually listening on the container port?
docker exec web ss -tlnp # something bound to :80 ?
# 3. Does it work from inside the container itself?
docker exec web curl -s http://localhost:80 # isolates app vs. mapping
The usual causes:
- No
-pflag. The most common one. The container works, other containers reach it, but you never published a port, so the host has no route in. Re-run with-p 8080:80. - Wrong side of the mapping. In
-p 8080:80you hit the host port (8080) from your browser, not 80. Curlinglocalhost:80when you published 8080 fails. Read thePORTScolumn indocker psto see the real mapping. - App bound to localhost inside the container. If the process listens on
127.0.0.1:80instead of0.0.0.0:80, the port publish forwards traffic to a port nothing is answering on. Bind the app to0.0.0.0. This one is sneaky because the container looks healthy anddocker psshows the mapping - but step 3 above (curl from inside) will fail too, pointing at the app rather than Docker. - Bound to localhost on the host on purpose. If you published with
-p 127.0.0.1:8080:80, it is reachable from the host but not from other machines. That is by design; widen it to-p 8080:80only if you actually want external access.
The shape of it
Docker networking is two questions, kept separate. Container to container is answered by the network: put both containers on the same user-defined bridge and they reach each other by name, thanks to Docker's embedded DNS. Host to container is answered by publishing: a container's ports are private until you map one with -p host:container, and only then can anything outside Docker reach it. The default bridge works but has no DNS, so reach for docker network create for anything real; host and none are the escape hatches for no isolation and no networking. And when something will not connect, the read is always the same - docker network inspect to confirm both sides share a network, docker ps to confirm the port is published, and ss -tlnp inside the container to confirm the app is listening on 0.0.0.0. Separate the two boundaries and the rest follows.