Docker Container Networking and Isolation
You will create user-defined bridge networks so containers reach each other by name while staying isolated from unrelated containers. By the end you will prove a database is reachable by its app but unreachable from outside its network.
Networking is where most multi-container deployments quietly go wrong: everything lands on the default bridge, every container can see every other container, and nothing resolves by name. This lab builds the opposite posture on purpose. You will stand up a two-tier application (an nginx frontend and a PostgreSQL database) on a private bridge, confirm Docker's embedded DNS resolves service names, then attach a hostile-by-omission "unrelated" container to a second bridge and prove it cannot reach the database at all. The same mental model carries directly into Docker Compose and Kubernetes, so treat this as foundational, not optional.
Learning Objectives
- Create user-defined bridge networks with built-in DNS.
- Connect containers by name instead of IP.
- Verify network isolation between groups.
- Inspect bridges, subnets, and attached containers with
docker network inspect. - Publish only the ports that must be public, and leave internal services unpublished.
- Time: ~3 hours · Difficulty: Intermediate · Prereqs: Docker installed.
Architecture Overview
Three ideas carry the whole lab. First, a user-defined bridge is a private Layer 2 segment: Docker creates a Linux bridge interface on the host, hands the network its own subnet (for example 172.18.0.0/16), and attaches each container with a virtual Ethernet pair. Second, every user-defined bridge gets an embedded DNS server inside each container at 127.0.0.11, which resolves container names to their current IPs on that network only. Third, bridges are isolated by default: kernel and iptables rules prevent traffic between bridges unless you explicitly connect a container to both, and nothing is reachable from outside the host unless you publish a port with -p.
The default bridge network that Docker attaches containers to when you specify nothing has none of the DNS behaviour, containers there can only find each other by raw IP address. That single difference is the reason every real deployment uses named networks.
Under the Hood: What Docker Actually Creates
You can treat bridge networks as magic, or you can look at the four moving parts once and never be confused by them again. When you run docker network create app-net and attach a container, the Docker daemon:
- Creates a Linux bridge interface named
br-<network-id-prefix>on the host and assigns it the network's gateway address (here,172.18.0.1). - Creates a veth pair per container: a virtual Ethernet cable with one end inside the container's network namespace (its
eth0) and the other plugged into the bridge. Traffic between containers on the same bridge is pure L2 switching, it never touches the host's routing table. - Programs iptables rules: a NAT masquerade rule so containers can reach the internet, isolation rules that drop packets crossing between bridges, and (only when you use
-p) a DNAT rule in theDOCKERchain that forwards the published host port to the container. - Starts the embedded DNS responder bound to
127.0.0.11:53inside each container's namespace, fronting the daemon's name table for that network.
On a native Linux host you can watch all of this directly, the interfaces appear and disappear as you create networks and start containers:
OBSERVE THE BRIDGE ON A LINUX HOST
ip link show type bridge
ip addr show br-$(docker network inspect -f '{{.Id}}' app-net | cut -c1-12)
sudo iptables -t nat -L DOCKER -n
Docker Desktop caveat (macOS and Windows): the daemon runs inside a lightweight Linux VM, so br-* interfaces and iptables rules live in that VM, not on your Mac. Everything in this lab still works identically from the container's point of view; the only visible difference is that published ports are forwarded through the VM, which is why localhost:8080 works at all on Desktop.
The practical payoff of knowing this: when isolation "leaks", it leaks at one of these four layers, and docker network inspect plus the iptables listing will show you which one.
Environment Setup
Prerequisites
- Docker Engine 24+ running locally on Linux or macOS.
- A terminal; no prior networking labs needed, but basic
docker runhelps. - Assumed skills: running commands and reading command output.
You will need: Docker and two or three small images (nginx and postgres work well).
Before you begin: the default bridge has no DNS; always use user-defined networks.
Confirm the engine is up and note its version. Everything below was written against Docker Engine 24.x/25.x, but any reasonably current release behaves the same:
Pull the three images up front so no step stalls waiting on a download. The alpine image is our general-purpose probe, busybox inside it ships ping and nc, which is all we need to test reachability:
PULL THE LAB IMAGES
docker pull nginx:stable
docker pull postgres:16
docker pull alpine:3.20
Finally, look at the networks that exist out of the box. You should see exactly three: bridge (the default), host, and none. If previous experiments left named networks behind, that is fine, just do not reuse their names in this lab.
Permissions note: if every command answers with permission denied while trying to connect to the Docker daemon socket, your user is not in the docker group. Either prefix every command here with sudo, or run sudo usermod -aG docker $USER and log out and back in. Docker Desktop on macOS and Windows handles this for you, so no setup is needed there.
Step-by-Step Execution
User-defined bridges provide automatic name resolution between attached containers.
docker network create app-netThe command returns the full 64-character network ID on success. Behind the scenes Docker allocates the next free private subnet, creates a bridge interface on the host (visible as br-<id> in ip link on Linux), and programs iptables rules that NAT outbound traffic and block inbound traffic from other bridges.
Common mistake: running this twice and getting Error response from daemon: network with name app-net already exists. That error is harmless, the network is already there and you can move on. If you genuinely want to start over, remove it with docker network rm app-net first (it must have no attached containers).
Optional hardening: pin the subnet yourself so it never collides with a VPN or LAN range, e.g. docker network create --subnet 10.90.0.0/24 app-net.
Expected output:
$ docker network create app-net
8f2c1a4b5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3
What this teaches: A user-defined network is a private bridge with its own subnet; creating it is what enables everything after.
Run the database first, then the frontend. The --name flag matters twice here: it gives you a handle for docker exec, and it becomes the DNS record other containers on app-net resolve. Note that the official postgres image refuses to start without a password, so we set POSTGRES_PASSWORD explicitly (a throwaway lab value, never use a real secret this way):
START THE DATABASE AND THE FRONTEND ON APP-NET
docker run -d --name db --network app-net \
-e POSTGRES_PASSWORD=labpass \
postgres:16
docker run -d --name web --network app-net nginx:stable
Expected output:
$ docker run -d --name web --network app-net nginx:stable
a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f
Flag by flag: -d detaches, --network app-net places the container on our bridge instead of the default one, and -e passes an environment variable into the container. Neither command publishes a port, so nothing is reachable from the host yet, exactly what we want for the database.
Verify both containers are actually running and on the right network:
Notice db shows 5432/tcp with no host mapping, the port is open inside the container but not published. If db is missing from the list, run docker ps -a; a postgres container that exits immediately almost always means the password variable was forgotten, which docker logs db will confirm.
Common mistake: omitting --network on one of the two containers. It silently lands on the default bridge, and step 03's name lookup will fail. When in doubt, check with docker inspect -f '{{json .NetworkSettings.Networks}}' db.
What this teaches: Attaching containers to the same network is what puts them on the same broadcast domain; placement is the policy.
Now prove the embedded DNS works. From inside web, resolve the name db:
This works because every container on a user-defined network gets a resolv.conf pointing at Docker's embedded DNS server, which listens on 127.0.0.11 inside the container's network namespace. That server forwards real internet lookups upstream but answers container names itself, and only for networks the asking container is attached to:
How to verify it is DNS and not luck: re-create db (docker rm -f db then re-run it) and resolve again. The IP may change, but getent hosts db still answers correctly because Docker updates its records on container start and stop. Anything caching the old IP, a connection pool, for instance, would need to reconnect, which is exactly why apps should connect by name and never pin container IPs.
Scoped aliases: besides its container name, a container can answer to extra names per network via --network-alias. A container started with --name db --network app-net --network-alias postgres-primary resolves under both names on app-net, useful for giving a container a stable service identity while the container name itself stays free to change.
Common mistake: testing from a container on the default bridge and concluding "DNS is broken". The default bridge has no embedded DNS names; the lookup only works between containers sharing a user-defined network.
Expected output:
$ docker exec web getent hosts db
172.19.0.3 db
What this teaches: Embedded DNS is the payoff: names resolve to current IPs, so restarts do not break connectivity.
Before trusting a topology, look at it. docker network inspect dumps the subnet, gateway, and every attached container with its IP:
EXPECTED OUTPUT (ABRIDGED)
[
{
"Name": "app-net",
"Driver": "bridge",
"IPAM": {
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
},
"Containers": {
"3f4a...": {
"Name": "db",
"IPv4Address": "172.18.0.2/16"
},
"9c1b...": {
"Name": "web",
"IPv4Address": "172.18.0.3/16"
}
}
}
]
Expected output:
$ docker network inspect app-net --format '{{.IPAM.Config}}'
[{172.19.0.0/16 172.19.0.1 map[]}]
For scripting, filter instead of reading the whole blob: docker network inspect -f '{{json .Containers}}' app-net. The Containers map is your ground truth for "who can talk to whom by name", if a container is not listed here, it is not on this network no matter what you intended.
What this teaches: Inspecting the bridge shows the machinery: subnets, gateways, and which containers hold addresses.
Name resolution alone does not prove the port is reachable. The nginx image is Debian-based and ships bash, so we can use bash's /dev/tcp pseudo-device as a dependency-free TCP probe, no need to install nc or curl:
Cross-check from the database side with postgres's own health tool, which confirms the server is not just listening but accepting connections:
Common mistake: declaring victory after ping db succeeds. ICMP only proves the container is up; the service can still be down or listening on a different port. Always probe the actual port, as above.
Optional, a real client handshake: if you want proof at the application layer rather than the TCP layer, run the postgres client from a throwaway container on the same network. It will authenticate and return a row:
This is the same pattern every real app follows: a service name (db), a port (5432), and credentials passed by environment variable, the exact triad you will later write into a Compose file or a Kubernetes manifest.
Expected output:
$ docker exec web sh -c 'echo > /dev/tcp/db/5432' && echo "db reachable"
db reachable
What this teaches: App-to-database connectivity is the real-world test; the app never knows it is in a container.
Isolation means nothing until something is actually on the outside trying to get in. Create a second bridge and attach a long-running Alpine container to it. This container stands in for anything that shares the Docker host but has no business talking to your database, a compromised sidecar, a neighbour tenant, a leftover debug container:
SECOND NETWORK PLUS AN OUTSIDER CONTAINER
docker network create other-net
docker run -d --name unrelated --network other-net alpine:3.20 sleep 3600
Expected output:
$ docker network create other-net
c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d
The sleep 3600 keeps Alpine alive for an hour; without a long-running command the container would exit instantly and have nothing to probe from.
Confirm the new topology before attacking it. docker network ls should now show four custom-relevant entries, and each network's inspect output should list exactly the containers you put there:
Note that other-net received its own subnet (172.19.0.0/16), Docker allocates these sequentially. The unrelated container has an interface on other-net only; it has no route into 172.18.0.0/16 at all.
What this teaches: A second network is a second broadcast domain; networks are the isolation boundary.
Attack it two ways. First, name resolution from unrelated fails outright, db is not a record on other-net, so the embedded DNS answers with nothing:
Second, and more important, even if the attacker learns the database IP out of band, the traffic itself is blocked because the two bridges are separate L2 domains with iptables rules between them:
That second test is the one that matters for security. DNS failure is a convenience barrier; the L2/iptables separation is the real wall. If you ever do need a container to span both worlds, say a monitoring agent, the surgical tool is docker network connect app-net unrelated, which adds a second interface to a running container rather than flattening the networks together.
Expected output:
$ docker exec outsider ping -c1 db
ping: bad address 'db'
What this teaches: A failed ping is a successful experiment: it proves the isolation is real, not assumed.
Right now the application works internally but is unreachable from the host, which is safe and useless in equal measure. The frontend is the piece the outside world is supposed to reach, so publish its port, and only its port. Ports are set at container creation time, so recreate web:
RECREATE WEB WITH A PUBLISHED PORT
docker rm -f web
docker run -d --name web --network app-net -p 8080:80 nginx:stable
Expected output:
$ docker ps --format '{{.Names}} {{.Ports}}'
web 0.0.0.0:8080->80/tcp
db 5432/tcp
Because web rejoins app-net under the same name, the DNS record is re-registered and nothing else changes. Verify from the host:
The pattern to internalize: the web tier is published (-p 8080:80), the database is not. An attacker who reaches the host can talk to nginx and nothing else; the database only exists on app-net. This is the container-era version of putting the DB in a private subnet.
Common mistake: publishing with -p 80:80 and colliding with something already on host port 80, or assuming -p can be added to a running container, it cannot. Port bindings are fixed at docker run time; changing them always means recreating the container, as we just did. Also note that EXPOSE in a Dockerfile is documentation only, it publishes nothing by itself.
What this teaches: Publishing one port is least privilege at the network edge; the database stays unreachable from the host.
Remove the containers first, a network with attached containers cannot be deleted, then the networks:
Do not tear down yet. The Testing & Validation section runs against these live containers and networks; the teardown commands are at its end.
Confirm with docker network ls and docker ps -a that only the three built-in networks and no lab containers remain. On a shared or long-lived host, docker network prune reclaims every unused custom network in one shot, useful, but be sure nothing else on the host is relying on an idle network before running it.
What this teaches: Cleanup removes the bridges and their iptables rules; leftover networks are a common source of confusion later.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
Steps 01-03 built and proved the inside of the network; steps 04-08 verified it from every angle, including the outside. Use the checklist below as a final end-to-end pass.
How It Works
Every Docker network is a Linux bridge with friends. Creating a network builds a bridge interface on the host, allocates a subnet, and attaches each container through a veth pair: one end inside the container's network namespace, the other on the bridge. iptables rules on the host then handle NAT for outbound traffic and port publishing for inbound. docker network inspect shows you this machinery in JSON form.
User-defined networks get an embedded DNS server at 127.0.0.11 inside each container, which resolves container names to their current IPs. The default bridge network deliberately lacks this: containers there can only reach each other by IP, which breaks the moment a container restarts with a new address. This single difference is why every serious compose file defines its own network.
Isolation in Docker is just non-shared bridges. Two containers on different user-defined networks have no path between them at layer 3, which you prove by watching the ping fail. Publishing a port with -p punches exactly one hole from the host into the container; everything else stays unreachable, which is the container analogue of a default-deny firewall.
Testing & Validation
docker run --rm --name probe alpine sh -c "nc -zv db 5432 || echo isolated"The probe (not on app-net) should print isolated, while web can reach db. That confirms isolation plus intended connectivity.
Run all four checks below after finishing the steps; the lab passes only if every row behaves as shown:
Check 3 is the one people skip, and the one that matters most. A green check 2 with a green check 3 is "isolated but working"; a green check 2 with a red check 3 means your isolation is an illusion and some container is bridged across networks it should not span.
Two notes on interpreting failures. If check 1 fails but check 2 works when you substitute the raw IP, DNS is the broken layer and the rest of the network is fine, look at which network the asking container is attached to. If checks 1 and 2 both fail, test docker exec web ping -c 2 172.18.0.2: a working ping with a failing port probe points at the postgres process itself (docker logs db), while a failing ping points at network membership (docker network inspect app-net). And on Docker Desktop, run check 4 from the Mac/Windows host exactly as shown, the port-forwarding VM makes localhost the correct target there too.
TEARDOWN
docker rm -f web db unrelated
docker network rm app-net other-net
Expected output:
$ docker network rm app-net other-net
app-net
other-net
Confirm with docker network ls and docker ps -a that only the three built-in networks and no lab containers remain.
Troubleshooting
Networking failures in Docker almost always reduce to one of four layers: DNS, network membership, iptables/port publishing, or the application itself. Work the list top-down, resolve the name first, then check which network the container is actually on, then check publishing, and only then blame the app. These are the five failures you will actually meet:
- Name not resolving: the default bridge lacks DNS; attach to a user-defined network. Verify with
docker exec <container> cat /etc/resolv.conf, if you do not seenameserver 127.0.0.11, the container is not on a user-defined bridge. Fix by recreating it with--network app-net. - Containers can all reach each other: they share one network; separate concerns into distinct networks.
docker network inspect <name>lists every attached container, ifunrelatedshows up underapp-net, disconnect it withdocker network disconnect app-net unrelated. - Port not reachable from host: publish it with
-p; network membership alone does not expose it. Also confirm the service binds0.0.0.0inside the container, a process bound to127.0.0.1in its own namespace is unreachable no matter how you publish. - postgres container exits immediately: the official image aborts when no password is set.
docker logs dbshowsError: Database is uninitialized and superuser password is not specified. Fix by passing-e POSTGRES_PASSWORD=...(orPOSTGRES_HOST_AUTH_METHOD=trustfor throwaway labs only). - "network with name app-net already exists" or IP pool exhaustion: inspect existing networks with
docker network ls; either reuse the existing network, remove the stale one withdocker network rm, or create yours with an explicit non-overlapping--subnet. Pool exhaustion shows up ascould not find an available, non-overlapping IPv4 address pooland means too many dead networks are squatting on RFC1918 ranges,docker network pruneclears them.
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| Name resolution fails between containers | Containers are on the default bridge, which has no embedded DNS | Create a user-defined network and attach both containers to it. |
| Connection refused on a published port | The app listens on localhost inside the container, or the port mapping is wrong | Check docker ps for the mapping and make the app bind 0.0.0.0. |
| Isolation test unexpectedly succeeds | Both containers are still attached to the first network | Inspect each container's networks; a container can hold memberships in several. |
| Subnet overlap errors | Docker's chosen subnet collides with your LAN or VPN | Create the network with an explicit --subnet outside your ranges. |
| DNS queries are slow | The embedded DNS forwarder struggling with the host resolver | Check the container's /etc/resolv.conf; it should point at 127.0.0.11. |
Extension Ideas
- Define the whole topology declaratively in Docker Compose, Compose creates a dedicated project network automatically, which is this lab's model with the manual steps removed.
- Move isolation to L3 in Kubernetes Services & Networking, where NetworkPolicy replaces per-bridge iptables as the enforcement point.
- Enforce policy with the Zero-Trust microsegmentation lab, which takes "default-deny between segments" from one Docker host to a whole environment.
- Add a second frontend container on
app-netand resolvewebfromdbto confirm DNS is symmetric, then use--network-alias apito give a container a stable service name independent of its container name.
Key Results
- Connected containers by name via user-defined DNS.
- Isolated unrelated containers onto separate networks.
- Proved both connectivity and isolation with a live probe.
- Established the networking model used by Compose and K8s.
Key Takeaways
- Always use user-defined bridge networks: the default bridge has no DNS and no meaningful isolation story.
- Container names are DNS records on their network, connect by name, never by pinned IP, because IPs change on recreate.
- Isolation is the default between bridges; verify it by attacking from a container on another network, not just by checking config.
- Publishing (
-p) is the only door from the host or outside world, publish frontends, leave databases unpublished. docker network inspectis ground truth: theContainersmap tells you exactly who can reach whom.
Practice Challenges
Challenge 1: Custom subnet with static IPs
Goal: Create a network with an explicit --subnet, assign static IPs to two containers, and prove they communicate.
Hint: Static IPs remove DHCP from the equation, which makes the DNS test cleaner.
Challenge 2: Prove the default bridge cannot resolve names
Goal: Run two containers on the default bridge and show name resolution failing while IP connectivity works.
Hint: The contrast with the user-defined network is the lesson; run both experiments back to back.
Challenge 3: Map veth to container
Goal: Use docker network inspect and ip link to match a veth interface to its container.
Hint: The interface index in the inspect output corresponds to the host-side veth name.