Skip to content

Docker Networking Fundamentals

"Why can't these two containers talk to each other" and "why can't I reach this container from the host" are two of the most common container questions, and both come down to understanding Docker's network drivers.

How it works

Network drivers

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
abc123def456   bridge    bridge    local
def456abc789   host      host      local
789abc123def   none      null      local

Every Docker install ships with three built-in networks:

  • bridge (default) — containers get their own private IP on a virtual bridge interface (docker0 on the host) and reach the outside world via NAT. On the default bridge, containers can reach each other by IP address but not by name; a user-defined bridge network adds automatic DNS resolution by container name, which is why running everything on the default network and juggling IPs by hand is considered bad practice.
  • host — the container shares the host's network namespace directly, no isolation, no port mapping needed (and none possible — the container binds host ports directly). Faster, but zero network isolation from the host.
  • none — no external networking: the container gets only a loopback interface.

User-defined bridge networks (the practical default for multi-container apps)

docker network create app_net
docker run -d --name db --network app_net postgres:16
docker run -d --name api --network app_net -e DB_HOST=db myapp

On a user-defined network, Docker runs an embedded DNS server that resolves container names to their IPs automatically — the api container can reach the database at the hostname db, with no manual IP configuration or /etc/hosts editing. This is the single biggest practical reason to always create an explicit network rather than relying on the default bridge.

Port publishing: -p explained

docker run -d -p 8080:80 nginx

-p <host_port>:<container_port> — this is NAT, not a direct bind: traffic hitting host_ip:8080 gets forwarded to port 80 inside the container. Critically, Docker manipulates the host's iptables/nftables rules directly to implement this — a ufw/firewalld rule that appears to block the port may have no effect, because Docker's own rules are evaluated first in a different chain. This is a very common source of confusion: an operator sets up ufw deny 8080, confirms with ufw status that it's denied, and the port is still reachable anyway, because ufw's rules don't automatically cover Docker's own DOCKER/DOCKER-USER chains. To publish only to the host itself, bind to loopback: docker run -d -p 127.0.0.1:8080:80 nginx.

docker port <container>              # see current port mappings for a running container

Inspecting a container's actual network config

docker inspect <container> --format '{{json .NetworkSettings.Networks}}' | python3 -m json.tool

This shows the container's actual IP address(es), gateway, and which network(s) it's attached to — useful when docker network ls confirms the network exists but you need to confirm a specific container is actually on it.

Container-to-container traffic without exposing a port to the host at all

Two containers on the same user-defined network can reach each other on any port the listening one exposes, with no -p flag needed at all — -p is only for exposing a port to the host machine / outside world. A database container that only the API container needs to reach should have no -p published for its database port — publishing it anyway exposes it to anything that can reach the host, not just the API container.

Ubuntu/Debian vs. RHEL-family differences

Docker's networking model is identical across distributions (it's implemented by the Docker daemon itself, not distribution-specific). The practical difference is firewall interaction: on a RHEL-family host running firewalld, Docker's manipulation of the underlying nftables/iptables rules can similarly bypass firewalld's own zone rules — the same "my firewall rule doesn't seem to apply to this container port" surprise applies there too.

Practical exercise

  1. Create a user-defined network and run two containers on it (e.g., nginx and a simple alpine container with sleep infinity).
  2. From inside the alpine container (docker exec -it <name> sh), run wget -qO- http://nginx (using the container name, not an IP) and confirm it resolves and responds.
  3. Run a third container not attached to that network and confirm the same wget by name fails — proving network membership, not just "Docker is running," is what grants reachability.

How to verify you did it correctly

  • Step 2: wget should succeed and print nginx's default HTML page, with no IP address used anywhere in the command.
  • Step 3: the same command from an unattached container should fail with a DNS resolution error (the name nginx is meaningless outside that specific user-defined network's embedded DNS).

Common mistakes

  • Assuming a ufw/firewalld deny rule blocks a Docker-published port. It frequently doesn't — Docker's own iptables rules (the DOCKER/DOCKER-USER chains) are evaluated independently. If you need the host firewall to actually govern Docker-published ports, rules need to go into the DOCKER-USER chain specifically, not the generic INPUT chain ufw/firewalld manage.
  • Publishing a port with -p for purely inter-container communication that didn't need to be exposed to the host/outside world at all.
  • Relying on the default bridge network and hardcoding container IPs, which change on recreation — a user-defined network's name-based DNS avoids this entirely.

⚠ Dangerous commands

  • docker network prune removes all networks not currently used by at least one container — if a network is defined but its containers are temporarily stopped (not removed), the network itself can still be pruned, requiring you to recreate it before starting those containers again.
  • Publishing a database or admin-panel port directly with -p <port>:<port> without a host firewall rule restricting source IPs exposes it to the entire network the host is reachable from — treat any -p the same way you'd treat opening a firewall port, because that's effectively what it does.

Review questions

  1. Why does the default bridge network not provide name-based resolution between containers, while a user-defined bridge network does?
  2. Why might a ufw deny <port> rule appear to have no effect on a port published by Docker?
  3. What's the practical difference between the bridge and host network drivers in terms of isolation?
  4. When would you not want to publish a container's port with -p, even though the port is genuinely in use?