Worked examples — Docker — images, containers, Dockerfile, docker-compose
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.

app.py invalidates only the COPY . . layer and below; the expensive pip install (green) stays cached.
- Check layer 1 (
FROM). Why this step? Docker rebuilds a layer only if its inputs changed. The base image tagpython:3.12-slimdidn't change → cache hit, reused. - Check layers 2–4. Why?
WORKDIR, theCOPY requirements.txt, andpip installall depend only onrequirements.txt, which you did not touch. All three → cache hit (green in s01). - Check layer 5 (
COPY . .). Why? This copies your whole source folder. You editedapp.py, so the copied content's hash changed → cache miss (red in s01), this layer rebuilds. - 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
- Layers 1–2 cached. Why? Base and
WORKDIRinputs unchanged → cache hits. - Layer 3 (
COPY . .) misses. Why? It copied everything includingapp.py; you changedapp.py→ content hash changes → rebuild. - Layer 4 (
pip install) misses too. Why? It sits below the miss. Even thoughrequirements.txtis identical, Docker can't reuse a layer whose parent changed → the full dependency install runs again. This is the pain. - 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.

-p 8080:8000 opens host door 8080 (yellow) and forwards it to container door 8000 (red). The browser knocks on the yellow door.
- 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. - The browser lives on the host. Why? You open
localhoston your own machine — so you knock on the host door (the yellow box in s02), which is the left number. - Therefore the blank is 8080. Traffic to host
8080is forwarded to container8000, where the app answers.
Example 4 — Case D: EXPOSE alone, no -p, and the -P flag
- What
EXPOSEdoes (answers D2). Why this step? From the parent note:EXPOSE 8000is 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-Pflag (next step). - 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.) - D3 — the
-P(publish-all) flag. Why does it change things? Capital-Ptells Docker: "publish every port listed byEXPOSE, 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. - How to find the assigned port. Why? Because it's random, you can't guess it. Run
docker port <id> 8000(or readdocker ps) → it prints the host port, e.g.0.0.0.0:49153. Then browselocalhost: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.

rm only if its path points at a mounted volume (X, green→yellow). A writable-layer-only write (Y, red) dies with the container.
- 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.
- Container Y (no volume). Why? Its row lands in the writable layer (dashed red box in s03).
docker rmdeletes the writable layer → the row is gone. Recreating gives a fresh empty layer. Y loses data. - Container X (named volume). Why? The path
/var/lib/postgresql/datais redirected to thepgdatanamed volume (yellow box in s03) — a folder Docker manages on the host, living outside any container.docker rmremoves the container but not the volume → row survives. X keeps data. - Answer: only X persists.
Example 6 — Case G: CMD vs ENTRYPOINT override arithmetic
ENTRYPOINTis the fixed prefix. Why this step? Whatever happens, the container runspython app.py ...—ENTRYPOINTis not overridden by trailingrunargs.CMDsupplies default args. Why? When the user passes nothing,CMD's list is appended. → G1 result:python app.py --port 8000.- User args replace
CMDentirely. Why? Trailing args ondocker runoverride the wholeCMD, not merge with it. So--port 9000replaces["--port","8000"]. → G2 result:python app.py --port 9000. - Key contrast. If you had used
CMD ["python","app.py"]alone (no ENTRYPOINT), thendocker run myapp bashwould replace everything and runbash— 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.

web reaches db by name with no IP addresses.
- Compose builds a shared network. Why this step? By default
docker compose upcreates one virtual network (the blue oval in s04) and attaches every service to it. - Each service name becomes a DNS hostname. Why? Compose registers
webanddbas names on that network's internal DNS. Sodbresolves to the db container's current IP automatically — no hard-coded IPs. - Port 5432 is the container's internal port. Why? Inside the shared network, containers talk on the container's own ports directly (no
-pneeded for service-to-service traffic). Postgres listens on5432. - What
depends_ondoes / doesn't do. Why care? It guaranteesdbstarts beforeweb. 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)
- Map the web port
5000:5000. Why? Requirement says the API is reachable at host5000; the Flask app listens on container5000. Host on left, container on right (Example 3's rule). - 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).
- Wire web → db by service name. Why? Use
postgres://db:5432/...per Example 7 — no IPs; the service namedbresolves 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:docker compose down(no-v). Why does data survive? Plaindownremoves containers and the network but keeps named volumes.pgdataremains, so on the nextupPostgres re-attaches it → data safe. Onlydocker compose down -vdeletes named volumes.
Example 9 — Case J: the exam twist (shared-layer disk math)
- 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.
- 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.)
- 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.
- 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.