Week 4: Docker Networking

Back in Week 1's exercise, one container couldn't ping another by name — that wasn't a bug, it was the default bridge network doing exactly what it's designed to do. This week explains Docker's network drivers properly: what isolation each one gives you, how container-name DNS actually works (and why it only works on networks you create yourself), and wiring two containers together the right way.

Module 4 of 17 Week 4 of 18 ~3 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain the difference between bridge, host and none network drivers
  • Create a user-defined network and use built-in DNS to connect containers by name
  • Wire a two-container app (API + database) together correctly, with only the API exposed

1. Network Drivers

Every container connects to at least one Docker network, and the network's driver decides what isolation and connectivity it gets. Three drivers matter for local development:

the three drivers
bridge   # DEFAULT. A private internal network on the host; containers get their own
         # IP, can reach the internet via NAT, and reach each other only if explicitly
         # allowed. This is what every "docker run" uses unless told otherwise.

host     # No network isolation at all — the container shares the host's network
         # stack directly. Fast (no NAT overhead), but zero port isolation: if your
         # app binds port 3000, it's bound on the host, full stop.

none     # No networking whatsoever. Used for batch jobs that only touch the
         # filesystem and have no business making network calls.
the default bridge network's limitation
docker network ls              # you'll see "bridge" listed by default
docker run -d --name a alpine sleep 3600
docker run -d --name b alpine sleep 3600
docker exec a ping b           # FAILS — no DNS resolution on the default bridge network
docker exec a ping $(docker inspect -f '{{.NetworkSettings.IPAddress}}' b)   # works, by IP

That's the exercise from Week 1 explained: the default bridge network (the one every plain docker run lands on) doesn't provide DNS between containers — only raw IP connectivity, and IPs change every time a container restarts. That limitation is exactly what Section 2 fixes.

host mode has real security implications

Because it removes network isolation entirely, host mode also removes one of the main defenses a container gives you — a compromised process can now bind to any port on your actual host. Reach for it only for genuine performance-critical cases, and never as a default.

2. User-Defined Networks & DNS

Create your own bridge network, and Docker gives you something the default bridge doesn't: automatic DNS resolution by container name.

a user-defined network fixes it
docker network create app-net

docker run -d --name a --network app-net alpine sleep 3600
docker run -d --name b --network app-net alpine sleep 3600

docker exec a ping b     # WORKS — "b" resolves via Docker's embedded DNS server
docker exec b ping a     # WORKS in both directions

Every user-defined bridge network runs an embedded DNS server that resolves container names (and Compose service names, which you'll meet in Week 5) to their current IP — automatically, even after a container restarts and gets a new IP. This is the mechanism that makes "my API just calls http://database:5432" work without ever hardcoding an IP address anywhere.

network management commands
docker network ls                    # list all networks
docker network inspect app-net       # see connected containers, subnet, gateway
docker network connect app-net some-other-container   # attach an existing container
docker network rm app-net            # delete a network (must have no containers attached)
Always create your own network

Never rely on the default bridge network for anything beyond a single throwaway container. Real setups — even a two-container demo — should always create a user-defined network first. It's also the network isolation boundary: containers on different user-defined networks can't reach each other unless explicitly connected to both.

3. Port Publishing

Container-to-container traffic (Section 2) never needs a published port — it's all internal to the Docker network. Port publishing (-p) is a completely separate concern: exposing a container's port to the outside world (your host machine, and beyond it, the internet).

-p syntax
docker run -p 8080:80 nginx
#          ^host  ^container
# "requests to host port 8080 get forwarded to container port 80"

docker run -p 127.0.0.1:8080:80 nginx
# bind ONLY to localhost — not reachable from other machines on the network

docker run -P nginx
# publish ALL exposed ports to random high host ports (rarely what you want)

A common point of confusion: EXPOSE in a Dockerfile is documentation only — it doesn't actually publish anything. Only the -p flag at docker run time creates a real host-to-container port mapping.

Don't publish your database's port

If your API and database talk to each other over a shared Docker network, the database never needs -p at all — publishing it just adds an unnecessary attack surface, exposing it to your whole host network for no benefit. Publish only what genuinely needs to be reached from outside Docker.

4. Wiring Two Containers Together, the Right Way

Putting Sections 1–3 together: an API and a database, on their own network, with only the API's port published.

terminal
docker network create backend-net

docker run -d --name db --network backend-net \
  -e POSTGRES_PASSWORD=devpw -e POSTGRES_DB=appdb \
  -v pg-data:/var/lib/postgresql/data \
  postgres:16
# NOTE: no -p flag — the database is reachable only from other containers on backend-net

docker run -d --name api --network backend-net \
  -e DATABASE_URL=postgres://postgres:devpw@db:5432/appdb \
  -p 3000:3000 \
  my-api:1.0
# The API connects using hostname "db" — resolved by Docker's embedded DNS —
# and its own port 3000 is the ONLY thing published to the host.

From the outside, only localhost:3000 exists. The database is completely unreachable except through the API — the network topology itself enforces that boundary, without a single firewall rule written by hand.

Looking ahead

Writing raw docker network create and docker run commands like this works, but it doesn't scale past two or three containers. Week 5 replaces this entire section with a single declarative YAML file and one command — the underlying networking concepts you just learned are exactly what's happening under the hood.

5. Hands-on Exercise

Hands-on

Build a two-container app on a user-defined network, then prove the isolation

Wire together a real API and database on your own network, then deliberately try to break its isolation from the outside to confirm it's real.

Part 1 — Wire it together:

  1. Create a user-defined bridge network.
  2. Run a Postgres (or MySQL/MongoDB) container on it, with a named volume for persistence and no published port.
  3. Run an API container (your own image from Week 2, or any simple app that can connect to a database by hostname) on the same network, connecting to the database by its container name, and publish only the API's port.
  4. Confirm the whole thing works end-to-end from your browser or curl at localhost:<api-port>.
Hint

If you don't have a real API on hand, an alpine container running apk add postgresql-client && psql -h db -U postgres is a fine stand-in — the point is proving hostname-based connectivity, not building a full API.

Part 2 — Prove the isolation from the outside:

  1. From your host machine (not inside any container), try to connect directly to the database's default port (5432 for Postgres) at localhost. It should fail — there's nothing published there.
  2. Run a THIRD container, on the default bridge network (don't specify --network), and try to reach the database by its container name from inside it. Confirm it fails, and explain in one sentence why — tying it back to what you learned in Section 1 about the default bridge network.
  3. Now connect that third container to your user-defined network too (docker network connect), without restarting it, and retry the same hostname lookup. It should now succeed.
  4. Run docker network inspect on your user-defined network and identify the container names, IPs, and subnet it assigned — write down what you see.
Hint

A container can belong to more than one network at once — docker network connect adds a network without removing any existing ones, which is exactly why step 3 works without recreating the container.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can't two containers on the default bridge network reach each other by container name?

The default bridge network doesn't run Docker's embedded DNS server for name resolution — only user-defined bridge networks do. On the default bridge, containers can only reach each other by raw IP address, which is also unreliable since IPs can change across restarts.

Q2

What's the difference between EXPOSE in a Dockerfile and -p at docker run?

EXPOSE is documentation baked into the image — it doesn't publish anything by itself. Only -p host:container at run time actually creates a mapping from a host port to the container's port, making it reachable from outside Docker.

Q3

Why is it good practice to NOT publish a database container's port when only your API needs to reach it?

Container-to-container traffic on a shared Docker network never needs a published port — it's internal. Publishing the database's port anyway exposes it to the entire host network (and potentially beyond) for no functional benefit, unnecessarily increasing the attack surface.

Q4

What's the main tradeoff of using host network mode?

Host mode removes network isolation entirely — the container shares the host's network stack directly, which avoids NAT overhead but means a compromised or misconfigured process can bind to any port on your actual host, eliminating one of a container's normal security boundaries.