Skip to content
BytePatterns

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.1 for 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 localhost between containers. Inside a container, localhost is 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:5432 only adds exposure.
  • Forgetting the host address. -p 8080:80 is not local-only. Write 127.0.0.1:8080:80 when 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