4.5.11 · D3Software Engineering

Worked examples — Docker — images, containers, Dockerfile, docker-compose

3,684 words17 min readBack to topic

This page is the practice arena for Docker — images, containers, Dockerfile, docker-compose. The parent note taught you the objects (image, container, Dockerfile, compose) and the layer system. Here we stress-test that knowledge against every scenario Docker can throw at you — every port direction, every caching outcome, every persistence pitfall, every degenerate input.

Before we start: two symbols you'll see a lot, plus one file that quietly controls caching.


The scenario matrix

Every Docker problem you'll ever debug falls into one of these case classes. Each row is a distinct kind of thing that can go right or wrong. The worked examples below are labelled with the cell they cover — together they hit every cell.

# Case class The question it forces Degenerate / edge version
A Layer caching — good order Which layers rebuild after an edit? Editing only source vs only deps
B Layer caching — bad order Why does a wrong order rebuild everything? COPY . . before install
C Port mapping — normal Can I reach the app from my browser? -p present and correct
D Port mapping — degenerate What if I forget -p? What does EXPOSE alone / -P do? No -p at all; EXPOSE-only; -P ephemeral host port
E Persistence — with volume Does data survive rm? Named volume mounted
F Persistence — without volume Where does data go on delete? Writable-layer-only writes
G CMD vs ENTRYPOINT override What actually runs, and can I override args? User passes extra args to run
H Multi-container / compose Can service X talk to service Y? By what name? DNS by service name
I Real-world word problem Ship a Flask + Postgres app end-to-end Full stack from scratch
J Exam twist A subtle gotcha that "feels" right Shared layers & disk math

Example 1 — Case A: caching after a code-only edit

Look at figure s01: the six layers are drawn as a stack. Layers 1–4 are outlined in green (cache hits), and 5–6 in red (rebuilt). The red arrow on the right shows how a miss "flows down" the stack.

Figure — Docker — images, containers, Dockerfile, docker-compose
Figure s01 — Editing app.py invalidates only the COPY . . layer and below; the expensive pip install (green) stays cached.

  1. Check layer 1 (FROM). Why this step? Docker rebuilds a layer only if its inputs changed. The base image tag python:3.12-slim didn't change → cache hit, reused.
  2. Check layers 2–4. Why? WORKDIR, the COPY requirements.txt, and pip install all depend only on requirements.txt, which you did not touch. All three → cache hit (green in s01).
  3. Check layer 5 (COPY . .). Why? This copies your whole source folder. You edited app.py, so the copied content's hash changedcache miss (red in s01), this layer rebuilds.
  4. Everything below a miss also rebuilds. Why? Layers are stacked; once one changes, its hash flows down (the red arrow). Layer 6 (CMD) is just metadata but is re-emitted. So exactly layers 5–6 rebuild.

Example 2 — Case B: the wrong order rebuilds everything

  1. Layers 1–2 cached. Why? Base and WORKDIR inputs unchanged → cache hits.
  2. Layer 3 (COPY . .) misses. Why? It copied everything including app.py; you changed app.py → content hash changes → rebuild.
  3. Layer 4 (pip install) misses too. Why? It sits below the miss. Even though requirements.txt is identical, Docker can't reuse a layer whose parent changed → the full dependency install runs again. This is the pain.
  4. Layer 5 re-emitted. So layers 3, 4, 5 rebuild — including the expensive install.

Example 3 — Case C: port mapping done right

Look at figure s02: the blue box on the left is your host with a yellow "port 8080" door; the green box on the right is the container with a red "port 8000" door where the app listens. The white arrow shows traffic flowing left → right.

Figure — Docker — images, containers, Dockerfile, docker-compose
Figure s02 — -p 8080:8000 opens host door 8080 (yellow) and forwards it to container door 8000 (red). The browser knocks on the yellow door.

  1. Read -p HOST:CONTAINER. Why this step? The left number is the door on the host machine (your laptop), the right is the door inside the container.
  2. The browser lives on the host. Why? You open localhost on your own machine — so you knock on the host door (the yellow box in s02), which is the left number.
  3. Therefore the blank is 8080. Traffic to host 8080 is forwarded to container 8000, where the app answers.

Example 4 — Case D: EXPOSE alone, no -p, and the -P flag

  1. What EXPOSE does (answers D2). Why this step? From the parent note: EXPOSE 8000 is documentation / metadata — it declares "this image intends to serve on container port 8000." By itself it opens zero host ports. Its real job is to feed the -P flag (next step).
  2. D1 — no -p, no -P. Why not reachable? Only a publish flag maps a host port. With neither, container port 8000 lives only inside the container's network namespace → host browser gets "connection refused." Not reachable. (Reachable host ports = 0.)
  3. D3 — the -P (publish-all) flag. Why does it change things? Capital -P tells Docker: "publish every port listed by EXPOSE, each to a random free ephemeral host port." So container 8000 becomes reachable — but not on host 8000; instead on some auto-assigned high port (e.g. 0.0.0.0:49153->8000/tcp). Reachable, but on an ephemeral host port you must look up.
  4. How to find the assigned port. Why? Because it's random, you can't guess it. Run docker port <id> 8000 (or read docker ps) → it prints the host port, e.g. 0.0.0.0:49153. Then browse localhost:49153.

Example 5 — Case E vs F: persistence with and without a volume

Look at figure s03: container Y (red, left) keeps its row in a dashed writable layer that gets deleted on rm; container X (green, right) redirects the same path into the yellow pgdata box that lives outside the container and survives.

Figure — Docker — images, containers, Dockerfile, docker-compose
Figure s03 — A write survives rm only if its path points at a mounted volume (X, green→yellow). A writable-layer-only write (Y, red) dies with the container.

  1. Where does a write land? Why this step? Every write goes into the container's thin writable layer unless that path is a mounted volume.
  2. Container Y (no volume). Why? Its row lands in the writable layer (dashed red box in s03). docker rm deletes the writable layer → the row is gone. Recreating gives a fresh empty layer. Y loses data.
  3. Container X (named volume). Why? The path /var/lib/postgresql/data is redirected to the pgdata named volume (yellow box in s03) — a folder Docker manages on the host, living outside any container. docker rm removes the container but not the volume → row survives. X keeps data.
  4. Answer: only X persists.

Example 6 — Case G: CMD vs ENTRYPOINT override arithmetic

  1. ENTRYPOINT is the fixed prefix. Why this step? Whatever happens, the container runs python app.py ...ENTRYPOINT is not overridden by trailing run args.
  2. CMD supplies default args. Why? When the user passes nothing, CMD's list is appended. → G1 result: python app.py --port 8000.
  3. User args replace CMD entirely. Why? Trailing args on docker run override the whole CMD, not merge with it. So --port 9000 replaces ["--port","8000"]. → G2 result: python app.py --port 9000.
  4. Key contrast. If you had used CMD ["python","app.py"] alone (no ENTRYPOINT), then docker run myapp bash would replace everything and run bash — you'd lose the app. ENTRYPOINT protects the executable.

Example 7 — Case H: can web reach db, and by what name?

Look at figure s04: both services sit inside one blue oval — the compose shared network. The green web box points a white arrow at the yellow db box, labelled postgres://db:5432, showing that the service name db is used directly as a hostname.

Figure — Docker — images, containers, Dockerfile, docker-compose
Figure s04 — Compose puts every service on one network and registers each service name in an internal DNS, so web reaches db by name with no IP addresses.

  1. Compose builds a shared network. Why this step? By default docker compose up creates one virtual network (the blue oval in s04) and attaches every service to it.
  2. Each service name becomes a DNS hostname. Why? Compose registers web and db as names on that network's internal DNS. So db resolves to the db container's current IP automatically — no hard-coded IPs.
  3. Port 5432 is the container's internal port. Why? Inside the shared network, containers talk on the container's own ports directly (no -p needed for service-to-service traffic). Postgres listens on 5432.
  4. What depends_on does / doesn't do. Why care? It guarantees db starts before web. It does NOT wait until Postgres is ready to accept connections — only that the container has launched. For true readiness you need a healthcheck or retry logic in the app.

Example 8 — Case I: the real-world word problem (full stack)

  1. Map the web port 5000:5000. Why? Requirement says the API is reachable at host 5000; the Flask app listens on container 5000. Host on left, container on right (Example 3's rule).
  2. Name a volume for Postgres. Why? Data must survive — mount a named volume at Postgres's data dir so it lives outside the container (Example 5's rule).
  3. Wire web → db by service name. Why? Use postgres://db:5432/... per Example 7 — no IPs; the service name db resolves via compose's internal DNS.
services:
  web:
    build: .
    ports:
      - "5000:5000"
    depends_on: [db]
    environment:
      - DATABASE_URL=postgres://db:5432/appdb
  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
  1. docker compose down (no -v). Why does data survive? Plain down removes containers and the network but keeps named volumes. pgdata remains, so on the next up Postgres re-attaches it → data safe. Only docker compose down -v deletes named volumes.

Example 9 — Case J: the exam twist (shared-layer disk math)

  1. Shared base is stored once. Why this step? Read-only layers are content-addressed and shared. The 120 MB base is identical for A and B → stored a single time, not twice.
  2. Compute Q1. Why? Disk = shared base (120) + A's unique layer (30) + B's unique layer (50). (The naive "320 MB" double-counts the base — that's the trap.)
  3. Containers share A's read-only layers. Why? All 4 containers from A reuse the same image layers on disk (0 extra). Only each writable layer is new.
  4. Compute Q2. Why? Extra disk = 4 containers × 5 MB writable each.

Recall One-line recap of the matrix

Caching order matters (A/B) ::: manifest before code so pip install stays cached. -p HOST:CONTAINER direction (C) ::: browser hits the host (left) number. EXPOSE vs -p vs -P (D) ::: EXPOSE = metadata (0 host ports); -p = you pick the host port; -P = random ephemeral host port per EXPOSEd port. Persistence (E/F) ::: data survives rm only if the path is a volume. CMD vs ENTRYPOINT (G) ::: ENTRYPOINT fixed executable; CMD default args; user args replace CMD. Compose networking (H/I) ::: service name = DNS hostname; depends_on = start order only. Shared layers (J) ::: base counted once; containers add only their writable layer.