Course 5 · Tooling

Docker Deeper

In Course 2 you ran someone else's container. Now you'll build your own image for a small data pipeline, understand why rebuilds are sometimes instant and sometimes slow, and wire two services together with Compose so a Python script can talk to a Postgres database by name. This is the leap from "I can use Docker" to "I package and ship software with Docker."

Setup — do this first

You need Docker installed and running — the same Docker you set up in Course 2. Open a terminal and run docker --version and docker compose version; both should print a version. If Docker Desktop isn't running, start it (the whale icon should be steady, not animating). Make a fresh working folder to follow along: mkdir -p ~/docker-deeper && cd ~/docker-deeper. Type every command — the fluency lives in your fingers.

Image vs container, in one breath

One distinction underpins everything in this chapter, so let's nail it. An image is a frozen, read-only template — a packaged filesystem plus the metadata that says "run this command when you start." A container is a running (or stopped) instance of that image — a live process with its own isolated filesystem layered on top.

ImageContainer
A template / blueprintA running instance of that template
Read-only, built onceHas a writable layer; starts & stops
Like a class in codeLike an object you created from the class
You build or pull itYou run it (one image → many containers)
terminal
docker run --rm python:3.12-slim python -c "print('hello from a container')"
✓ You should see

Docker pulls the python:3.12-slim image if you don't have it, starts a container, prints hello from a container, and removes the container (--rm) when it exits. The image stays cached on your machine; only the container was disposable. That image-stays / container-goes split is the whole mental model.

Writing a Dockerfile — building your own image

A Dockerfile is a plain-text recipe: a list of instructions Docker follows, top to bottom, to assemble an image. Let's build one for a tiny Python "pipeline." First, the two files our image will contain.

terminal — create the app files
cat > requirements.txt <<'EOF'
requests==2.32.3
EOF

cat > pipeline.py <<'EOF'
print("[pipeline] starting")
nums = [120, 80, 200, 95]
total = sum(nums)
print(f"[pipeline] processed {len(nums)} records, total = {total}")
print("[pipeline] done")
EOF

Now the Dockerfile. Each instruction is purposeful — read the comments:

Dockerfile
# FROM: the base image we build on top of. "slim" = small Debian + Python.
FROM python:3.12-slim

# WORKDIR: set (and create) the working directory inside the image.
# Every later COPY/RUN/CMD runs relative to here.
WORKDIR /app

# COPY dependencies FIRST (before the app code) — see the next section for why.
COPY requirements.txt .

# RUN: execute a command at BUILD time and bake the result into the image.
RUN pip install --no-cache-dir -r requirements.txt

# Now copy the rest of the application code.
COPY pipeline.py .

# CMD: the default command run when a container STARTS (at run time).
CMD ["python", "pipeline.py"]

Build the image (the -t gives it a name/tag), then run a container from it:

terminal
docker build -t mypipeline:v1 .     # the trailing "." = build context (this folder)
docker run --rm mypipeline:v1
✓ You should see

The build streams steps like [1/4] FROM, [3/4] RUN pip install, and ends with naming to docker.io/library/mypipeline:v1. The run then prints [pipeline] startingprocessed 4 records, total = 495done. You just built and ran software that will behave identically on any machine with Docker — laptop, teammate's laptop, or a server.

CMD vs ENTRYPOINT

CMD sets the default command, and anything you append after docker run image … replaces it. ENTRYPOINT sets a fixed command that always runs, and CMD becomes its default arguments. Rule of thumb: use CMD for "here's what to run by default" (our case); use ENTRYPOINT when the image is a specific tool and run-time args should be passed to it.

Layers & the build cache

Here's the idea that makes Docker fast once you understand it: every instruction in a Dockerfile creates a layer, and Docker caches each layer. On a rebuild, Docker reuses a cached layer as long as nothing it depends on changed. The moment one instruction's inputs change, that layer and every layer after it are rebuilt.

Dockerfile instruction → cached layer ────────────────────────────────────────────────────────── FROM python:3.12-slim → [ base OS + Python ] ← rarely changes WORKDIR /app → [ + workdir set ] COPY requirements.txt . → [ + deps manifest ] ← changes when deps change RUN pip install ... → [ + installed packages ] ← the SLOW layer COPY pipeline.py . → [ + your code ] ← changes constantly CMD ["python","pipeline.py"] → [ + start command ] Edit ONLY pipeline.py → Docker reuses everything down to the last COPY, skips the slow pip install, rebuilds in a blink.

This is why we copied requirements.txt and ran pip install before copying the app code. Your code changes dozens of times a day; your dependencies rarely do. By ordering the cheap-but-frequent change (code) after the expensive-but-rare one (installing packages), most rebuilds skip the slow pip install entirely. Prove it to yourself:

terminal
# Change only the app code, not the dependencies
echo 'print("[pipeline] v2 — extra line")' >> pipeline.py

docker build -t mypipeline:v2 .
✓ You should see

The FROM, WORKDIR, COPY requirements.txt, and RUN pip install steps all show CACHED. Only the final COPY pipeline.py and CMD actually rerun. The build finishes in well under a second instead of re-downloading packages. If you'd put COPY pipeline.py before the install, every code edit would re-run pip — the classic beginner mistake.

Two more habits keep images small and builds clean:

TechniqueWhy it helps
Slim / alpine base imagespython:3.12-slim is a fraction of the full python:3.12. Smaller images push, pull, and start faster.
.dockerignore fileExcludes junk (.git, __pycache__, local data) from the build context, so it isn't sent to the builder or baked into layers.
--no-cache-dir on pipStops pip from caching wheels inside the image — bytes you'd ship forever for no benefit.
Combine RUN stepsEach RUN is a layer; chaining with && and cleaning up in the same line avoids leftover cruft in earlier layers.
.dockerignore
.git
__pycache__/
*.pyc
.env
data/
*.md

Multi-stage builds — ship a lean runtime

Sometimes building your app needs heavy tools (compilers, build dependencies) that the running app doesn't. Shipping those tools bloats the image and widens its attack surface. A multi-stage build uses one stage to build and a second, clean stage to run — copying only the finished artifacts across.

Dockerfile (multi-stage)
# ---- Stage 1: builder — has the tools to install dependencies ----
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
# Install packages into a self-contained folder we can copy out
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

# ---- Stage 2: runtime — lean image, only what's needed to RUN ----
FROM python:3.12-slim
WORKDIR /app
# Copy ONLY the installed packages from the builder stage
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY pipeline.py .
CMD ["python", "pipeline.py"]

The final image contains your code and your dependencies — but none of the build-time scratch work. For a Python script the savings are modest; for compiled languages or apps with build toolchains, multi-stage can shrink an image from gigabytes to tens of megabytes. The pattern is the same everywhere: build fat, ship thin.

Compose, deeper — wiring services together

A single container is rarely a whole system. A real data stack is several containers — a database, a script, maybe a scheduler — that must find and talk to each other. Docker Compose describes that whole stack in one YAML file and brings it up with one command. You met Compose in Course 2; now we use its connective tissue.

Compose conceptWhat it does
servicesEach container in the stack. Service name becomes its hostname.
networksCompose puts all services on a shared network; they reach each other by service name (no IPs).
volumes (named)Docker-managed storage that survives container removal — for databases.
bind mountsMap a host folder into the container (./code:/app) — for live-editing code in dev.
environment / env_filePass config (passwords, hosts) into containers as env vars.
depends_on + healthcheckControl start order and wait until a dependency is actually ready, not just started.
Service name = hostname (the key idea)

By default Compose creates one network for your stack and joins every service to it. Inside that network, a container reaches another by its service name as if it were a DNS hostname. So a service named db is reachable at the host db on its port — no IP addresses, no manual networking. This is exactly how the next section's script will connect to Postgres.

Named volume vs bind mount trips people up, so hold both in mind:

Named volumeBind mount
pgdata:/var/lib/postgresql/data./pipeline.py:/app/pipeline.py
Docker manages where it livesYou point at a specific host path
Best for persistent data (databases)Best for live code in development
Survives docker compose downJust a window into your host folder

Image registries — push & pull

You built mypipeline:v1 locally — but how does a teammate or a production server get it? Through a registry: a server that stores and serves images. Docker Hub is the default public one; GHCR (GitHub Container Registry) is popular because it sits next to your code. docker pull python:3.12-slim already used a registry — you were pulling from Docker Hub.

You publish an image so the exact bytes you tested run unchanged elsewhere — no rebuilding, no "works on my machine." The flow is tag → log in → push:

terminal — illustrative (needs a real account)
# Tag the local image with a registry path: registry/namespace/name:tag
docker tag mypipeline:v1 ghcr.io/yourname/mypipeline:v1

docker login ghcr.io                       # authenticate once
docker push ghcr.io/yourname/mypipeline:v1 # upload it

# Anyone (with access) can now run the very same image:
docker pull ghcr.io/yourname/mypipeline:v1
⚠ Tags are mutable — pin what matters

A tag like :latest is just a moving label; whoever pushes last owns it. For anything reproducible, use explicit version tags (:v1, :2026-06-21) so a deploy can't silently change underneath you. "It worked yesterday and nothing changed" is often a :latest that quietly moved.

Hands-on: a script that talks to Postgres

Now the payoff. We'll run a two-service stack — our Python pipeline and a Postgres database — on a shared network, and have the pipeline connect to Postgres by service name. This is a miniature of every data platform you'll build.

First, update the pipeline to actually connect to a database. Replace pipeline.py and add the Postgres driver:

terminal — rewrite the app
cat > requirements.txt <<'EOF'
psycopg[binary]==3.2.1
EOF

cat > pipeline.py <<'EOF'
import os, time, psycopg

# "db" is the Compose SERVICE NAME — that's the hostname we connect to.
dsn = (
    f"host={os.environ['DB_HOST']} "
    f"dbname={os.environ['POSTGRES_DB']} "
    f"user={os.environ['POSTGRES_USER']} "
    f"password={os.environ['POSTGRES_PASSWORD']}"
)

print("[pipeline] connecting to Postgres...")
with psycopg.connect(dsn) as conn:
    with conn.cursor() as cur:
        cur.execute("CREATE TABLE IF NOT EXISTS sales (region TEXT, amount INT);")
        cur.execute("INSERT INTO sales VALUES ('us', 120), ('eu', 80), ('us', 200);")
        cur.execute("SELECT region, SUM(amount) FROM sales GROUP BY region ORDER BY region;")
        for region, total in cur.fetchall():
            print(f"[pipeline] {region} total = {total}")
print("[pipeline] done")
EOF

Now the Compose file. Note how depends_on waits for Postgres to be healthy, and how the pipeline reaches the database at host db — the service name:

compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume → data persists
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d warehouse"]
      interval: 3s
      timeout: 3s
      retries: 10

  pipeline:
    build: .                              # build from our Dockerfile
    environment:
      DB_HOST: db                         # ← the service name above
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    depends_on:
      db:
        condition: service_healthy        # wait until Postgres is READY

volumes:
  pgdata:                                 # declare the named volume

Bring up the stack. --build rebuilds the pipeline image first:

terminal
docker compose up --build

# In another terminal (or after it exits), tidy up.
# down       = stop & remove containers/network (named volume survives)
# down -v    = also delete the named volume (wipes the database)
docker compose down
✓ You should see

Postgres logs start up, then report database system is ready to accept connections. Compose waits for the healthcheck to pass, then starts the pipeline, which prints connecting to Postgres..., then eu total = 80 and us total = 320, then done and exits. The two containers found each other purely by the name db — no IPs anywhere. Run docker compose up again and the totals double, because the named volume kept the table; run down -v first to start clean.

Why the healthcheck matters

A Postgres container is "started" within milliseconds but isn't ready to accept connections for a second or two. Without condition: service_healthy, your pipeline races ahead and crashes with "connection refused." The healthcheck turns "is it running?" into "is it actually usable?" — a distinction that saves you from flaky, intermittent startup failures.

✓ Check yourself

  • Can you explain, in one sentence, the difference between an image and a container?
  • Why do we COPY requirements.txt and install deps before copying the app code?
  • How does one container reach another in a Compose stack — by IP, or by something else?
  • What's the difference between a named volume and a bind mount, and which one would you use for a database?
Exercise — Containerize a script and compose it with Postgres

From scratch in an empty folder: write a Dockerfile that installs dependencies from requirements.txt and runs app.py; then write a compose.yaml that builds it as a service alongside a postgres:16 service named db, on the default shared network, with the app waiting for the database to be healthy. The app should connect to host db and print one row.

solution — Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
solution — compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d warehouse"]
      interval: 3s
      retries: 10

  app:
    build: .
    environment:
      DB_HOST: db
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:
solution — app.py & requirements.txt
# requirements.txt -> psycopg[binary]==3.2.1
import os, psycopg
dsn = (f"host={os.environ['DB_HOST']} dbname={os.environ['POSTGRES_DB']} "
       f"user={os.environ['POSTGRES_USER']} password={os.environ['POSTGRES_PASSWORD']}")
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
    cur.execute("SELECT 'connected to ' || current_database();")
    print(cur.fetchone()[0])

Run docker compose up --build. If you see connected to warehouse after Postgres reports ready, you've built and composed a real two-service stack — the same shape as the platform you'll wire up next, just bigger.

Next

You can build images and compose services that find each other by name — now let's scale that up into a real local data platform with several cooperating services. → Wiring the Mini-Platform