← Learn
Docker Course  /  Phase 1  /  Module 04
Day 4 ~2h · hands-on
Phase 1 · Docker · Day 4

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.

~2h · hands-on heavy builds on Modules 00–03 bridge · DNS · ports · drivers Docker 29.5 · CachyOS
0

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.

Why networking matters right now

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.

1

The network model: what actually happens

When Docker starts a container, it does four things under the hood to give it connectivity:

  1. Creates a new NET namespace for the container (its isolated stack).
  2. 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.
  3. Puts one end of that pair (eth0) inside the container's namespace and assigns it an IP address from the bridge's subnet.
  4. 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.

Internet / external networkoutside the host
Host NIC (eth0 / wlan0)host network namespace
iptables NAT / routingmasquerades container traffic
docker0 bridge (172.17.0.0/16)virtual switch on host
veth pair: one end per containercontainer ↔ bridge wire
Container eth0 (172.17.0.2, .3, …)inside NET namespace
The one sentence to remember

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.

2

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.

DriverWhat it doesWhen 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 path to Kubernetes

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.

3

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.

FeatureDefault bridgeUser-defined bridge
Container-to-container by IPYesYes
Container-to-container by name (DNS)NoYes, automatic
ScopeAll containers without a network spec land here together, with no isolation between unrelated containersOnly containers you explicitly connect are in the network
Created howAutomatically on Docker install, always existsdocker network create mynet
Connect/disconnect without restartNoYes, 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

terminal
# 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
The rule you always apply

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.

4

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

docker run -p 8080:80 nginx
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.

port publishing examples
# 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.

Security: 0.0.0.0 vs 127.0.0.1

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.

5

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:

  1. Create a user-defined bridge network.
  2. Run both containers on that network, giving each a --name.
  3. In the application config, set the database host to the container's name, and the embedded DNS handles the rest.
app + db on a shared network
# 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.

Forward reference: Module 05

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.

6

Inspecting & managing networks

The full docker network subcommand family. You will use these constantly when debugging connectivity issues.

CommandWhat it does
docker network lsList all networks on the host. Shows driver and scope.
docker network create mynetCreate a user-defined bridge network named mynet.
docker network create --driver host mynetCreate a network with a specific driver.
docker network create --subnet 192.168.10.0/24 mynetCreate with a custom subnet.
docker network inspect mynetFull JSON details: subnet, gateway, all connected containers and their IPs.
docker network connect mynet container1Attach a running container to an additional network (live, no restart).
docker network disconnect mynet container1Detach a running container from a network (live).
docker network rm mynetRemove a network (only works if no containers are attached).
docker network pruneRemove all unused networks.

Finding a container's IP address

inspect for IPs
# 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
7

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.

Classic failure scenario

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.

reaching host services from Linux containers
# 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.

quick gotcha reference
# 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
8

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 ls and 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 --network flag). Confirm the ping by name fails. Then get the second container's IP via docker inspect and confirm ping by IP works.
  • Run an nginx container with -p 8080:80. Open a browser to http://localhost:8080 or curl localhost:8080 from 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 testnet and 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. Run docker inspect on it and find both network entries in the output.
  • Clean up: docker stop and docker rm your test containers, then docker network prune to remove unused networks.
Build, don't just run

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.