Docker Networking Explained: Bridge Networks, Ports, Volumes
8 min readBytePatterns
Docker networking explained: why names resolve only on user-defined networks, what -p 8080:80 really exposes, and why data belongs in volumes, with a toy model.
Three Docker behaviours cause most of the "it works on my machine" confusion in container setups, and all three come up in platform and backend interviews. A container on the default network cannot find another container by name. docker run -p 8080:80 exposes the port to more than your laptop. And a database container that is removed and recreated comes back empty unless its data lived in a volume. Everything below comes from the Docker documentation pages listed at the end, as of September 2026.
The problem it solves
Take an API container and a database container. The API must find the database, your browser must reach the API, and the rows must survive an image upgrade. Each needs a different Docker feature:
- Networks decide which containers can talk to each other, and whether they can use names.
- Published ports decide what can be reached from outside the Docker host.
- Volumes decide which files outlive the container that wrote them.
The intuition
Names need a user-defined network. When you run a container without --network, it joins the default network, called bridge. The Docker docs are explicit that containers on the default bridge can only reach each other by IP address, unless you use the legacy --link option. Create your own network with docker network create app-net and attach both containers to it, and Docker's embedded DNS server resolves each container's name to its address, so the API connects to db:5432. It also isolates better: every container started without a network option shares the default bridge.
Nothing outside the host gets in until you publish. Containers on the same bridge network can reach each other's ports without publishing anything. Traffic from outside the host needs -p: -p 8080:8000 maps host port 8080 to container port 8000. The detail that surprises people is the address. With no host address, Docker publishes on all host addresses, 0.0.0.0 and [::], and the documentation calls publishing ports insecure by default for exactly that reason. -p 127.0.0.1:8080:8000 limits access to the Docker host itself.
Data belongs in volumes. Files a container writes go to its writable layer, and that layer is destroyed with the container. A volume's contents exist outside the lifecycle of any container, so mounting a named volume at the database's data directory means a replacement container finds every row. Bind mounts, which map a host directory, suit live code in development but depend on the host's directory layout; volumes are managed by Docker.
Watch it run
The animation starts with two containers on a user-defined network called app-net. api connects to db:5432, and Docker's DNS resolves the container name to db's address. On the default bridge network the same name does not resolve; there, containers only reach each other by IP. Back on app-net, nothing outside the host can reach api yet, because no port is published. Then -p 127.0.0.1:8080:8000 maps host port 8080 to api's port 8000, for this machine only. Leave the address off, as in -p 8080:8000, and Docker binds every host address, so the outside world can reach it too. Then the data. Without a volume, the rows Postgres writes land in the container's writable layer, which is destroyed with the container, so removing db for an upgrade takes every row with it. Mount a named volume at the data directory instead, and it lives outside any container's lifecycle: the new db mounts pgdata, and every row is still there. The summary frame: names on a user-defined network, one port published on purpose, and data in volumes.
Ports, Networks & Volumes
Step 1 of 12
Two containers on a user-defined network called app-net.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's commands, a private network, a named volume and one port published to the host only:
# illustrative — a private network, a named volume, one published port
docker network create app-net
docker volume create pgdata
docker run -d --name db --network app-net -e POSTGRES_PASSWORD=dev \
-v pgdata:/var/lib/postgresql/data postgres:17
docker run -d --name api --network app-net -p 127.0.0.1:8080:8000 my-api
A toy model of those three rules, not Docker: names resolve only between containers that share a user-defined network, plain IP traffic needs any shared network, a published port without an address is reachable from the local network, and writes go to a volume only under a mount path. Addresses are illustrative:
class ToyDocker:
"""Toy model of one Docker host's networks, ports and volumes, not Docker."""
def __init__(self):
self.networks = {"bridge": {}} # network -> {container: ip}
self.next_ip = {"bridge": 2}
self.containers, self.volumes, self.ports = {}, {}, []
def network_create(self, name):
self.networks[name], self.next_ip[name] = {}, 2
def run(self, name, network="bridge", mounts=None, publish=None):
subnet = list(self.networks).index(network) + 17
self.networks[network][name] = f"172.{subnet}.0.{self.next_ip[network]}"
self.next_ip[network] += 1
self.containers[name] = {"layer": [], "mounts": mounts or {}}
for vol in (mounts or {}).values():
self.volumes.setdefault(vol, []) # named volume, created on first use
if publish: # (host address or None, host port, container port)
host_ip, host_port, port = publish
self.ports.append((host_ip or "0.0.0.0", host_port, name, port))
def resolve(self, client, target):
"""Embedded DNS: names resolve only on user-defined networks both are on."""
for net, members in self.networks.items():
if net != "bridge" and client in members and target in members:
return members[target]
return None
def reach_ip(self, client, ip):
return any(client in m and ip in m.values() for m in self.networks.values())
def from_outside(self, source, host_port): # source: "host" or "lan"
for host_ip, hp, name, port in self.ports:
if hp == host_port and (host_ip == "0.0.0.0" or source == "host"):
return f"{name}:{port}"
return "refused"
def volume_for(self, name, path):
for mount_path, vol in self.containers[name]["mounts"].items():
if path.startswith(mount_path):
return vol
return None
def write(self, name, path, row):
vol = self.volume_for(name, path)
(self.volumes[vol] if vol else self.containers[name]["layer"]).append(row)
def rows(self, name, path):
vol = self.volume_for(name, path)
return list(self.volumes[vol] if vol else self.containers[name]["layer"])
def rm(self, name):
del self.containers[name] # the writable layer goes with it
self.ports = [p for p in self.ports if p[2] != name]
for members in self.networks.values():
members.pop(name, None)
The lesson's setup, next to two containers started without --network. Names fail on the default bridge, the API answers only the host, and after remove-and-recreate only the database with a volume keeps its row:
d = ToyDocker()
d.network_create("app-net")
d.run("db", "app-net", mounts={"/var/lib/postgresql/data": "pgdata"})
d.run("api", "app-net", publish=("127.0.0.1", 8080, 8000))
d.run("old-db") # no --network: the default bridge
d.run("old-api")
print(d.resolve("api", "db")) # 172.18.0.2
print(d.resolve("old-api", "old-db")) # None
print(d.reach_ip("old-api", "172.17.0.2")) # True
print(d.reach_ip("api", "172.17.0.2")) # False
print(d.from_outside("host", 8080), d.from_outside("lan", 8080)) # api:8000 refused
d.run("web", "app-net", publish=(None, 9090, 80)) # no address: every host address
print(d.from_outside("lan", 9090)) # web:80
PGDATA = "/var/lib/postgresql/data"
d.write("db", PGDATA + "/base", "row 1")
d.write("old-db", PGDATA + "/base", "row 1")
d.rm("db"); d.rm("old-db") # upgrade: remove, then recreate
d.run("db", "app-net", mounts={PGDATA: "pgdata"})
d.run("old-db")
print(d.rows("db", PGDATA), d.rows("old-db", PGDATA)) # ['row 1'] []
The model checked against brute force on 2,000 random hosts: names recomputed from each network's member list, data recomputed by replaying every run, write and removal, and each published port tested from both sides:
import random
def brute_resolve(t, client, target):
# read it from the network side: every user-defined network's member list
shared = [n for n in t.networks if n != "bridge" and {client, target} <= set(t.networks[n])]
return t.networks[shared[0]][target] if shared else None
def brute_rows(log, reader, path):
# replay the whole history: which writes can this reader still see?
seen = []
for i, (kind, name, *rest) in enumerate(log):
if kind != "write":
continue
wpath, row, wvol = rest
rvol = next(e[2] for e in reversed(log) if e[0] == "run" and e[1] == reader)
rvol = rvol.get("/data") if path.startswith("/data") else None
if wvol or rvol:
if wvol == rvol:
seen.append(row) # same volume: survives everything
elif name == reader and ("rm", reader) not in log[i:]:
seen.append(row) # writable layer: gone after rm
return seen
random.seed(22)
ok = True
for _ in range(2000):
t, log = ToyDocker(), []
nets = ["bridge"] + [f"net{i}" for i in range(random.randint(0, 3))]
for n in nets[1:]:
t.network_create(n)
names = [f"c{i}" for i in range(random.randint(2, 6))]
for k, n in enumerate(names):
mounts = {"/data": random.choice(["v1", "v2"])} if random.random() < 0.5 else {}
t.run(n, random.choice(nets), mounts, (random.choice([None, "127.0.0.1"]), 8000 + k, 80))
log.append(("run", n, mounts))
for a in names:
for b in names:
if a != b:
ok &= t.resolve(a, b) == brute_resolve(t, a, b)
ok &= t.resolve(a, b) is None or t.reach_ip(a, t.resolve(a, b))
for step in range(12):
n = random.choice(names)
if random.random() < 0.2: # remove and recreate on the bridge
mounts = t.containers[n]["mounts"]
t.rm(n)
log.append(("rm", n))
t.run(n, "bridge", mounts)
log.append(("run", n, mounts))
else:
path = random.choice(["/data/x", "/tmp/x"])
vol = t.volume_for(n, path)
t.write(n, path, step)
log.append(("write", n, path, step, vol))
for n in names:
for path in ("/data/x", "/tmp/x"):
ok &= t.rows(n, path) == brute_rows(log, n, path)
for host_ip, hp, name, _ in t.ports:
ok &= t.from_outside("host", hp) == f"{name}:80"
ok &= (t.from_outside("lan", hp) == "refused") == (host_ip == "127.0.0.1")
print(ok) # True
The complexity
The costs here are exposure and data loss rather than steps:
- Discovery: a name lookup replaces hard-coded IP addresses, which can change when a container is recreated.
- Exposure: every port published without an address is reachable from anything that can reach the host. Publish the minimum, and bind to
127.0.0.1for local tools. - Durability: the writable layer lives exactly as long as the container. Named volumes survive
docker rm, and anonymous volumes are removed automatically when the container was started with--rm.
Where it goes wrong
- Relying on the default bridge. Names do not resolve there. Create a network and attach both containers.
- Using
localhostbetween containers. Inside a container,localhostis that container. Use the other container's name. - Publishing a database port by habit. Services on the same network reach it without publishing;
-p 5432:5432only adds exposure. - Forgetting the host address.
-p 8080:80is not local-only. Write127.0.0.1:8080:80when that is what you mean. - Keeping state in the writable layer. It disappears on
docker rm.
When it shows up in interviews
It comes up as "why can't my API reach my database?", "what does -p actually do?" and "how do you keep a container's data?" in Docker and platform rounds. The same service-by-name idea is automated by Docker Compose, which creates a network per project, and it is where Kubernetes Services and Ingress and StatefulSets with persistent volumes take over on a cluster. The isolation underneath is in containers vs virtual machines.
How to say it in an interview
"I put the API and the database on a user-defined bridge network, because only there does Docker's embedded DNS resolve container names, so the API connects to db:5432. On the default bridge they could only use IP addresses. Containers on the same network need no published ports; I publish only the API, and I bind it to 127.0.0.1 unless it really should be reachable from outside, because without an address Docker publishes on every host address. The database writes to a named volume, since the container's writable layer is destroyed with the container, and the volume lets a replacement container pick up every row."
Sources
- Networking overview — Docker Docs
- Bridge network driver — Docker Docs
- Port publishing and mapping — Docker Docs
- Volumes — Docker Docs