Lesson 3 - Networks

Welcome to Networks

Lesson 1 mentioned it in passing: Compose creates a network for you, with no docker network create anywhere in the file. This lesson stops treating that as a footnote and looks at it directly — what that network actually is, what docker network inspect reports about it, and what “containers reach each other by service name” really means when you watch it happen rather than take it on faith.

By the end of this lesson, you will be able to:

  • Find and inspect the network a Compose stack creates, without ever running docker network create yourself.
  • Explain why a container reaches another one with its service name, not an IP address or a hand-typed container name.
  • Predict, and confirm, that the host machine itself is not on that network by default.
  • Connect this back to Module 1, where the same isolation existed but had to be built by hand.

The stack: a database and a network-check client

Two services — db, the same Postgres you’ve used all course, and client, a small Python container whose entire job is to resolve db by name and open a real connection to it:

# gate: skip
"""CityFlow's network-check client - resolves 'db' by service name and
opens a real TCP connection to it, proving Compose's default network
resolves service names with no manual docker network create anywhere."""
import socket

ip = socket.gethostbyname("db")
print(f"resolved db -> {ip}")
s = socket.create_connection(("db", 5432), timeout=5)
print("TCP connect to db:5432 succeeded")
s.close()

Save this as client.py, and the stack itself as docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: cityflow
      POSTGRES_DB: cityflow

  client:
    image: python:3.13-slim
    depends_on:
      - db
    volumes:
      - ./client.py:/client.py:ro
    command: ["python", "/client.py"]

Nothing in either file mentions a network by name. There’s no docker network create, no --network flag, no network section in the YAML at all. Bring up just db first:

docker compose -p cf-net-demo up -d db
 Network cf-net-demo_default Creating
 Network cf-net-demo_default Created
 Container cf-net-demo-db-1 Creating
 Container cf-net-demo-db-1 Created
 Container cf-net-demo-db-1 Starting
 Container cf-net-demo-db-1 Started

Read that output again: Network cf-net-demo_default Creating — the very first line, before either container. Compose created a network before it created anything else, entirely on its own, named after the project (cf-net-demo, from -p) with _default appended. This is the network every service in this file joins unless you tell it otherwise.


Inspect it directly

docker network ls --filter name=cf-net-demo
NETWORK ID     NAME                  DRIVER    SCOPE
9058f9c99900   cf-net-demo_default   bridge    local

A real network, same driver (bridge) as the one you created by hand with docker network create in Module 1 — Compose isn’t using some different, special mechanism, it’s running the exact same command you’d run yourself, automatically, at the moment it’s needed.

docker network inspect cf-net-demo_default --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
cf-net-demo-db-1: 172.26.0.2/16

db is attached, with a real IP address on this network — 172.26.0.2. You never assigned that address; Compose’s network driver did, the same way it would for a network you created yourself. Only db shows here because client hasn’t been started yet.


The proof: resolve and connect by name

Run client as a one-off task against the db that’s already up:

docker compose -p cf-net-demo run --rm client
 Container cf-net-demo-db-1 Running
 Container cf-net-demo-client-run-0913dfd5fb3f Creating
 Container cf-net-demo-client-run-0913dfd5fb3f Created
resolved db -> 172.26.0.2
TCP connect to db:5432 succeeded

resolved db -> 172.26.0.2 — the exact IP docker network inspect reported a moment ago. client.py never hardcoded that address; it asked the operating system to resolve the hostname "db", and Compose’s network gave it back the right answer, because db is the name of the service in the YAML. Change that service’s name to anything else and this same resolution would follow it automatically — the name in the file is the name on the network, always in sync, which is exactly the property Lesson 1 called out as missing from the hand-typed --network/-h version.

Confirm db is still the only container that stays attached once client finishes and removes itself:

docker network inspect cf-net-demo_default --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
cf-net-demo-db-1: 172.26.0.2/16

client ran with --rm behavior baked into docker compose run, so once its script finished, the container was removed and its network endpoint released — the same disposable-task pattern Lesson 1’s reporter used, just proven here from the network’s own point of view.


The host is still not on this network

Module 1’s guided project ended with an exercise proving your host terminal couldn’t reach cityflow-db by name, even though a container on the same network could. Compose’s automatic network has the identical property — it isn’t more permissive just because you didn’t have to create it yourself:

# gate: setup: mkdir -p /tmp/cf-net-demo && printf 'services:\n  db:\n    image: postgres:16-alpine\n    environment:\n      POSTGRES_PASSWORD: cityflow\n      POSTGRES_DB: cityflow\n\n  client:\n    image: python:3.13-slim\n    depends_on:\n      - db\n    volumes:\n      - ./client.py:/client.py:ro\n    command: ["python", "/client.py"]\n' > /tmp/cf-net-demo/docker-compose.yml && printf 'import socket\nip = socket.gethostbyname("db")\nprint(f"resolved db -> {ip}")\ns = socket.create_connection(("db", 5432), timeout=5)\nprint("TCP connect to db:5432 succeeded")\ns.close()\n' > /tmp/cf-net-demo/client.py && docker compose -f /tmp/cf-net-demo/docker-compose.yml -p cf-net-demo-gate up -d db >/dev/null && sleep 3
# gate: teardown: docker compose -f /tmp/cf-net-demo/docker-compose.yml -p cf-net-demo-gate down -v >/dev/null 2>&1; rm -rf /tmp/cf-net-demo
"""Prove Compose's default network resolves service names between
containers automatically, and that the host itself is not on that network."""
import socket
import subprocess

# The host is not attached to the stack's network, so it cannot resolve
# a Compose service name - the same isolation Module 1 built by hand.
try:
    socket.gethostbyname("db")
    print("host resolved 'db' - unexpected")
except socket.gaierror:
    print("host cannot resolve 'db' (expected - the host isn't on the network)")

# Inside the network, the client container resolves 'db' and connects for real.
result = subprocess.run(
    ["docker", "compose", "-f", "/tmp/cf-net-demo/docker-compose.yml",
     "-p", "cf-net-demo-gate", "run", "--rm", "client"],
    capture_output=True, text=True, check=True,
)
print(result.stdout.strip())
assert "TCP connect to db:5432 succeeded" in result.stdout
host cannot resolve 'db' (expected - the host isn't on the network)
resolved db -> 172.26.0.2
TCP connect to db:5432 succeeded

Same script, run from this very machine’s own Python — the process running this gate can’t resolve db at all, while a client container on cf-net-demo_default resolves and connects to it instantly. That’s not two different mechanisms; it’s the same DNS resolution mechanism, scoped to whoever is actually attached to the network.

A diagram showing a dashed boundary labeled cf-net-demo_default, created automatically by Compose with no docker network create command anywhere. Inside it, two containers: db (postgres:16-alpine) at IP 172.26.0.2, and client (python:3.13-slim), with an arrow from client to db labeled socket.gethostbyname 'db' resolves to 172.26.0.2, then a second arrow labeled TCP connect to db:5432 succeeds. Outside the dashed boundary, a laptop icon labeled host machine with a crossed-out arrow toward db, captioned host cannot resolve 'db' - not on this network. A small caption at the bottom notes this is the identical isolation Module 1 built with a manually created docker network create command; Compose creates and names the same kind of network without being asked.
The network Compose made without being told to, inspected directly: real IP addresses, real name resolution, and the same host-side isolation Module 1 built by hand.

Clean up

docker compose -p cf-net-demo down
 Container cf-net-demo-db-1 Stopping
 Container cf-net-demo-db-1 Stopped
 Container cf-net-demo-db-1 Removing
 Container cf-net-demo-db-1 Removed
 Network cf-net-demo_default Removing
 Network cf-net-demo_default Removed

One command, same as Lesson 1 — the network Compose created without being asked gets removed the same way, without you having to remember its generated name to do it.


Practice Exercises

Exercise 1: Reproduce the whole inspection

Bring up db alone, inspect the network before and after running client, and confirm the resolved IP address in client’s output matches what docker network inspect reports.

Hint

Run docker network inspect cf-net-demo_default --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}' exactly as shown in this lesson — the --format string is Go template syntax, not shell syntax, so keep the quoting exactly as written.

Exercise 2: Add a third service and predict what happens

Add a third service, client2, identical to client but with a different service key. Without running anything, predict whether client2 will also successfully resolve db. Then test it.

Hint

Every service in the same docker-compose.yml joins the same default network unless a service explicitly opts out, so client2 resolving db should work identically to client — the service name is what matters, not which service is asking.

Exercise 3: Give the network an explicit name

Compose lets you name the default network yourself instead of accepting the project-prefixed generated one. Add a top-level networks: section with a default: key and a name: override, and confirm docker network ls now shows your chosen name instead of cf-net-demo_default.

Hint

networks:
  default:
    name: cityflow-net

Add this alongside services: at the top level of the file — the rest of the stack needs no changes, since services already refer to each other by service name, not by network name.


Summary

You watched Compose create a network before it created either container, inspected it directly with docker network inspect, and confirmed a client container resolved the database’s service name to the exact IP address that command reported — no docker network create, no --network flag, anywhere in either file. The host machine itself stayed off that network the whole time, unable to resolve the same hostname the client container resolved instantly — the identical isolation Module 1 built by hand with its own docker network create, produced here with zero network-specific lines in the Compose file at all.

Key Concepts

  • Compose’s default network — created automatically, before any service starts, named <project>_default unless overridden.
  • Service-name resolution — any service reaches any other service in the same file using its service key as a hostname; the network’s own DNS keeps that in sync.
  • docker network inspect — reports real IP addresses and currently-attached containers for any network, Compose-created or not.
  • Host isolation by default — the machine running Compose is not itself on the stack’s network, exactly like a manually created network in Module 1.
  • networks: default: name: — the one line needed to give Compose’s automatic network a name of your choosing instead of the generated one.

Why This Matters

Every multi-service stack in the rest of this course — Lesson 4’s data stack, Lesson 5’s guided project, and every Compose file you write after this course — depends on this exact mechanism to let its pieces find each other, and now you’ve watched it happen at the network layer instead of trusting that -h db “just works.” Module 1 taught you what a Docker network is; this lesson showed you that Compose doesn’t replace that model, it just automates the one step — creation — that used to be yours to remember.


Continue Building Your Skills

You now have all three pieces this module has built so far: a declarative file instead of hand-typed commands, data that survives a container being recreated, and containers that find each other by name automatically. Lesson 4 puts them to work together for the first time — a real Postgres database and a real Python job, both defined as services in one Compose file, running as an actual local data stack rather than a lesson-sized demo.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com