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."
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.
| Image | Container |
|---|---|
| A template / blueprint | A running instance of that template |
| Read-only, built once | Has a writable layer; starts & stops |
| Like a class in code | Like an object you created from the class |
You build or pull it | You run it (one image → many containers) |
docker run --rm python:3.12-slim python -c "print('hello from a container')"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.
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")
EOFNow the Dockerfile. Each instruction is purposeful — read the comments:
# 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:
docker build -t mypipeline:v1 . # the trailing "." = build context (this folder)
docker run --rm mypipeline:v1The 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] starting … processed 4 records, total = 495 … done. You just built and ran software that will behave identically on any machine with Docker — laptop, teammate's laptop, or a server.
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.
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:
# Change only the app code, not the dependencies
echo 'print("[pipeline] v2 — extra line")' >> pipeline.py
docker build -t mypipeline:v2 .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:
| Technique | Why it helps |
|---|---|
| Slim / alpine base images | python:3.12-slim is a fraction of the full python:3.12. Smaller images push, pull, and start faster. |
.dockerignore file | Excludes 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 pip | Stops pip from caching wheels inside the image — bytes you'd ship forever for no benefit. |
| Combine RUN steps | Each RUN is a layer; chaining with && and cleaning up in the same line avoids leftover cruft in earlier layers. |
.git
__pycache__/
*.pyc
.env
data/
*.mdMulti-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.
# ---- 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 concept | What it does |
|---|---|
| services | Each container in the stack. Service name becomes its hostname. |
| networks | Compose 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 mounts | Map a host folder into the container (./code:/app) — for live-editing code in dev. |
| environment / env_file | Pass config (passwords, hosts) into containers as env vars. |
| depends_on + healthcheck | Control start order and wait until a dependency is actually ready, not just started. |
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 volume | Bind mount |
|---|---|
pgdata:/var/lib/postgresql/data | ./pipeline.py:/app/pipeline.py |
| Docker manages where it lives | You point at a specific host path |
| Best for persistent data (databases) | Best for live code in development |
Survives docker compose down | Just 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:
# 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:v1A 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:
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")
EOFNow 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:
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 volumeBring up the stack. --build rebuilds the pipeline image first:
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 downPostgres 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.
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.txtand 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.
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"]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:# 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