Module 04: Networking
Bridges, Ports & Container DNS
Sam has containers that run. Now they need to talk, to each other and to the outside world. Sam's first containerized app, a web service plus a database, runs each piece fine in isolation but cannot connect them. This module explains how Docker wires containers together and why one configuration detail, the user-defined bridge, is what every multi-container app depends on.
Where we left off
Module 00 introduced Linux namespaces as the kernel mechanism that isolates containers from each other and from the host. One of those namespaces, the NET namespace, gives each container its own completely isolated network stack: its own network interfaces, its own routing table, its own IP address, and its own port space. From inside a container, the network looks like a freshly booted machine that happens to have no physical NIC.
That isolation is good for security. But taken alone, it means containers are islands. They cannot reach each other or the internet. This is exactly where Sam is stuck: the app container is up, the database container is up, and neither can see the other. Docker's job in this module is to build the bridges between those islands without collapsing the isolation that makes them safe.
NET namespace
Each container gets its own isolated network stack: its own lo, its own eth0, its own IP.
Docker bridges the gap
Virtual ethernet pairs (veth) connect each container's namespace to a bridge on the host.
Host NATs outbound traffic
The host's iptables rules masquerade container traffic so it can reach the internet.
Module 05 is Docker Compose, which is entirely about wiring multi-container apps together. Everything Compose does with networking is a thin wrapper over what you are learning here. Understand the fundamentals now, and Compose will feel obvious rather than magical.
The network model: what actually happens
When Docker starts a container, it does four things under the hood to give it connectivity:
- Creates a new NET namespace for the container (its isolated stack).
- Creates a virtual ethernet pair (veth pair), a pair of virtual NICs joined end-to-end. Think of it as a network cable with a NIC at each end.
- Puts one end of that pair (
eth0) inside the container's namespace and assigns it an IP address from the bridge's subnet. - Attaches the other end to a Linux bridge on the host (e.g.
docker0).
The bridge is a virtual switch on the host. Every container attached to the same bridge can talk to each other
through it, just as machines on the same LAN switch can talk to each other. The host's kernel also sets up
iptables NAT rules so containers can reach the internet through the host's real NIC.
A container's network isolation comes from its NET namespace; Docker's connectivity comes from veth pairs attached to a bridge. These two facts explain everything else in this module.
Network drivers: five modes, one table
Docker supports multiple network drivers. Each is a different answer to the question "how should
this container's network namespace connect to the outside world?" You pick the driver when you create a network with
docker network create --driver <name>. If you don't pick, you get bridge.
| Driver | What it does | When to use it |
|---|---|---|
bridge |
Default. Creates a Linux bridge on the host. Containers on the same bridge network can talk to each other. Docker manages IP assignment and (on user-defined networks) DNS. | Single-host, multi-container apps. The right choice 90% of the time for development and small deployments. |
host |
The container shares the host's network namespace directly, with no isolation. The container's process listens on the host's ports. No veth pair, no bridge. Fastest possible networking, zero overhead. | Performance-critical workloads where the NAT overhead matters (e.g. high-throughput proxies). Use sparingly. Linux only. |
none |
The container gets a NET namespace but no external connectivity, only a loopback interface. Completely airgapped. | Batch jobs that must not reach the network, or as a base when you will manually configure networking. |
overlay |
Multi-host networking for Docker Swarm clusters. Creates an encrypted VXLAN tunnel across hosts so containers on different machines appear on the same L2 network. | Docker Swarm services. Also the conceptual ancestor of Kubernetes pod networking, where each K8s CNI plugin (Flannel, Calico, Cilium) implements a similar idea, connecting pods across nodes in a cluster. |
macvlan |
Assigns a real MAC address and a real IP from your LAN's subnet directly to the container. The container appears as a first-class device on the physical network, visible to routers and other LAN devices. | Legacy apps that expect to be directly addressable on the LAN; network monitoring tools that need a real MAC; any scenario where NAT is unacceptable. |
The overlay driver is the single-host taste of what Kubernetes does at scale. Every K8s node runs a CNI (Container Network Interface) plugin that gives each pod its own IP and connects pods across nodes. Flannel uses VXLAN (just like Docker overlay). Calico uses BGP. Cilium uses eBPF. They all solve the same fundamental problem: make containers on different machines look like they're on the same network. Once you understand Docker's bridge model, the K8s networking model is the same concept scaled out.
host: the speed trade-off
No NAT, no bridge overhead, no port mapping. The container's process binds directly to the host's port. But you lose all network isolation: a bug in the container can bind to host ports and interfere with host services.
macvlan: the LAN trick
Real IP, real MAC, visible on the LAN. Great for legacy apps. The catch: most Wi-Fi drivers do not support promiscuous mode, so macvlan usually requires a wired interface or a virtual machine.
Default bridge vs user-defined bridge
This is the single most important distinction in Docker networking, and it is a core concept. Read this section carefully.
Docker ships with a pre-created network called bridge (backed by the kernel bridge
docker0). When you run a container without specifying a network, it lands on this default
bridge automatically. The problem is what the default bridge does not provide.
| Feature | Default bridge | User-defined bridge |
|---|---|---|
| Container-to-container by IP | Yes | Yes |
| Container-to-container by name (DNS) | No | Yes, automatic |
| Scope | All containers without a network spec land here together, with no isolation between unrelated containers | Only containers you explicitly connect are in the network |
| Created how | Automatically on Docker install, always exists | docker network create mynet |
| Connect/disconnect without restart | No | Yes, live connect/disconnect |
The DNS point is the critical one. On the default bridge, containers can only find each other by their
IP address, which changes every time you restart a container. That makes anything but the simplest
setup fragile. On a user-defined bridge, Docker runs an embedded DNS server that resolves container
names to their current IP addresses automatically. You can address a database by the name db
and it will always resolve correctly, even after a restart.
Creating a user-defined bridge and using it
# Create a user-defined bridge network
docker network create mynet
# Run two containers on it, giving them names
docker run -d --name app --network mynet nginx:alpine
docker run -d --name db --network mynet postgres:16-alpine
# From 'app', reach 'db' by NAME: this works because of the embedded DNS
docker exec app ping -c 3 db
PING db (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.123 ms
...
# Now try the SAME thing on the default bridge: it will FAIL by name
docker run -d --name c1 nginx:alpine
docker run -d --name c2 nginx:alpine
docker exec c1 ping -c 1 c2
ping: bad address 'c2' <-- no DNS on default bridge
# But it WORKS by IP (fragile: IP changes on restart)
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' c2
172.17.0.3
docker exec c1 ping -c 1 172.17.0.3
PING 172.17.0.3: 56 data bytes <-- works, but only until c2 restarts
For any multi-container application, always create a user-defined bridge. Never rely on the default bridge for inter-container communication. Docker Compose follows this rule automatically, creating a project-scoped user-defined network for every Compose project. This is exactly why Compose services can reach each other by service name.
Publishing ports: connecting containers to the host
Containers are isolated. Their ports are only reachable from inside the same Docker network by default. To make a
service reachable from the host machine (or from outside it), you must publish a port. Publishing
tells Docker to add an iptables rule that forwards traffic arriving on a host port to the
container's port.
The -p flag, broken down
command HOST_PORT:CONTAINER_PORT image
Traffic arriving at port 8080 on the host is forwarded into the container's port 80. The container port is fixed by what the app listens on. The host port is whatever is free and convenient on your machine.
# Publish a specific host port → container port
docker run -p 8080:80 nginx
# Publish multiple ports
docker run -p 80:80 -p 443:443 nginx
# Let Docker pick a random available host port (use docker port to find it)
docker run -p 80 nginx
docker port <container-id> 80
0.0.0.0:49153
# -P: publish ALL EXPOSEd ports to random host ports
docker run -P nginx
# Bind to localhost ONLY, not reachable from outside the host
docker run -p 127.0.0.1:8080:80 nginx
# Explicitly bind to all interfaces (the default when no address given)
docker run -p 0.0.0.0:8080:80 nginx
EXPOSE vs -p: the thing everyone confuses
EXPOSE 80 in a Dockerfile is documentation. It tells humans and
tooling "this container's application listens on port 80." It does not make any port reachable from the
host or from the internet. Nothing is actually published until you use -p at
docker run time.
EXPOSE = documentation
Written in the Dockerfile. Tells operators which port to publish. Used by -P to know which ports to forward. Does not open anything.
-p = actually publishes
Passed at docker run. Creates an iptables rule. Makes the port reachable from the host (or beyond). This is what opens the port.
When you run -p 8080:80, the default binding address is 0.0.0.0
meaning the port is reachable from any network interface, including external ones. On a cloud VM,
this exposes the port to the internet (subject to your firewall rules). If you are running a dev database and
only want it accessible from your own machine, use -p 127.0.0.1:5432:5432. This is a
real-world security consideration, not just theory.
Container-to-container communication
The most common real-world scenario: an application container that needs to reach a database container. This is Sam's exact problem from the start of the module, the app and the database that could not see each other. It is also the pattern that underpins every multi-container app, and it is exactly what Docker Compose was built to automate.
The recipe is simple:
- Create a user-defined bridge network.
- Run both containers on that network, giving each a
--name. - In the application config, set the database host to the container's name, and the embedded DNS handles the rest.
# 1. Create the shared network
docker network create appnet
# 2. Start the database container
docker run -d \
--name db \
--network appnet \
-e POSTGRES_PASSWORD=secret \
postgres:16-alpine
# 3. Start the application container
docker run -d \
--name app \
--network appnet \
-p 8000:8000 \
-e DATABASE_URL=postgres://postgres:secret@db:5432/mydb \
myapp:1.0
# Note: the hostname in DATABASE_URL is "db", the container name.
# Docker's embedded DNS resolves "db" to the db container's current IP.
# No IP addresses in config. No hardcoded values that break on restart.
Notice that app publishes its port 8000 to the host with -p,
so users can reach the web app. But db has no -p flag.
It is reachable only from other containers on appnet, which is exactly the right security
posture for a database. This is the moment Sam's app finally ships: the web service answers on the published port,
it talks to the database by name, and the database stays private.
This three-step pattern (create network → run containers → use name as hostname) is precisely what
docker compose up does for you from a YAML file. Each service becomes a container,
Docker Compose creates a project-scoped network, and each service is reachable by its service name. The
manual steps you just learned are what Compose is automating.
Inspecting & managing networks
The full docker network subcommand family. You will use these constantly when debugging
connectivity issues.
| Command | What it does |
|---|---|
docker network ls | List all networks on the host. Shows driver and scope. |
docker network create mynet | Create a user-defined bridge network named mynet. |
docker network create --driver host mynet | Create a network with a specific driver. |
docker network create --subnet 192.168.10.0/24 mynet | Create with a custom subnet. |
docker network inspect mynet | Full JSON details: subnet, gateway, all connected containers and their IPs. |
docker network connect mynet container1 | Attach a running container to an additional network (live, no restart). |
docker network disconnect mynet container1 | Detach a running container from a network (live). |
docker network rm mynet | Remove a network (only works if no containers are attached). |
docker network prune | Remove all unused networks. |
Finding a container's IP address
# Full JSON: look for NetworkSettings.Networks
docker inspect mycontainer
# Just the IP on the default bridge
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mycontainer
172.17.0.3
# See which containers are on a network, and their IPs
docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
app 172.18.0.2/16
db 172.18.0.3/16
# List all networks on the host
docker network ls
NETWORK ID NAME DRIVER SCOPE
a1b2c3d4e5f6 bridge bridge local
b2c3d4e5f6a1 host host local
c3d4e5f6a1b2 none null local
d4e5f6a1b2c3 appnet bridge local
Common gotchas
These are the mistakes that actually cost people hours. They are all common enough to bite you in real work as "what would you check first if X is broken?"
localhost inside a container is not the host
This is the most frequent confusion. Inside a container, localhost (or
127.0.0.1) refers to the container's own loopback interface, not the host machine's
loopback. If your app inside the container tries to connect to localhost:5432, it is trying
to reach a Postgres that must also be inside that same container. If Postgres is running as a separate
container or directly on the host, localhost will not find it.
Developer runs Postgres natively on their laptop at localhost:5432. Puts their app in
a Docker container with DATABASE_URL=postgres://localhost:5432/mydb. App fails to
connect. Localhost inside the container does not mean the laptop. Use
host.docker.internal instead.
Connecting to host services from inside a container
Docker Desktop (Mac and Windows) provides the special hostname
host.docker.internal which resolves to the host machine's IP from inside any container.
On Linux with Docker Engine (not Desktop), you need to pass --add-host=host.docker.internal:host-gateway
when starting the container, or use the host network driver.
# On Linux (Docker Engine without Desktop), add this flag:
docker run --add-host=host.docker.internal:host-gateway myapp
# Now inside the container, this resolves to the host machine:
host.docker.internal → 172.17.0.1 (the bridge gateway = host)
# On Docker Desktop (Mac/Windows), it works automatically, no flag needed
Port already in use
If docker run -p 8080:80 fails with "port is already allocated," something on the host
is already bound to port 8080. Find it with ss -tlnp | grep 8080 or
lsof -i :8080. Either stop that process or pick a different host port.
Default bridge has no DNS
If two containers on the default bridge cannot reach each other by name, that is not a bug. It is by design. The default bridge predates Docker's embedded DNS. The fix is always the same: move them to a user-defined bridge. This is not a workaround; it is the correct architecture.
# Symptom: "Connection refused" connecting to localhost from inside container
# Cause: localhost resolves to the container's own loopback, not the host
# Fix: use host.docker.internal (or move the service to a container)
# Symptom: ping by name fails between two containers
# Cause: they're on the default bridge (no DNS)
# Fix: create a user-defined bridge and move both containers to it
docker network create mynet
docker network connect mynet container1
docker network connect mynet container2
# Symptom: -p 8080:80 fails with "address already in use"
# Fix: find and stop whatever holds the host port
ss -tlnp | grep 8080
Hands-on: do this now
Networking is the kind of topic that feels clear when you read it and murky when you try it. The commands below are short. Do not skip them.
- Run
docker network lsand identify which three networks exist by default (bridge,host,none) and what driver each uses. - Create a user-defined bridge:
docker network create testnet. Run two Alpine containers on it with distinct names. From one, ping the other by name. Confirm it works. - Repeat the same test on the default bridge (no
--networkflag). Confirm the ping by name fails. Then get the second container's IP viadocker inspectand confirm ping by IP works. - Run an nginx container with
-p 8080:80. Open a browser tohttp://localhost:8080orcurl localhost:8080from the host. Confirm you get nginx's default page. - Run the same nginx with
-p 127.0.0.1:8081:80. Confirm it is reachable from localhost but think through why it would not be reachable from another machine on your network. - Run
docker network inspect testnetand find the subnet, gateway, and the IP addresses of the connected containers in the JSON output. - Connect a running container to a second network with
docker network connect. Rundocker inspecton it and find both network entries in the output. - Clean up:
docker stopanddocker rmyour test containers, thendocker network pruneto remove unused networks.
For the container-to-container exercise, try writing a tiny Python or Node app that actually makes an HTTP request to the other container's name as hostname. The point of this course is that you should be able to sketch the wiring from memory. Commands you have typed yourself stick far better than ones you have only read.