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/5] FROM, [4/5] 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
Answer from memory before opening each one.
-
A teammate's Dockerfile reads
FROM,WORKDIR /app,COPY . .,RUN pip install -r requirements.txt,CMD. Every one-line edit topipeline.pycosts them a full dependency install. Why, and what reordering fixes it?Show answer
Each instruction is a cached layer, and when one layer's inputs change, that layer and every layer after it rebuild.
COPY . .includes the code, so any edit invalidates it and thepip installbelow it. Copyrequirements.txtand install first, then copy the code last — the slow layer now only rebuilds when dependencies change. See Layers & the build cache. -
You run the hands-on stack with
docker compose up, let it finish, rundocker compose down, thendocker compose upagain. What totals does the pipeline print foreuandusthe second time, why, and which command would have given you 80 and 320 again?Show answer
eu total = 160andus total = 640.downremoves the containers and network, but the named volumepgdatasurvives, so thesalestable still holds the first run's rows and the script inserts the same three again.docker compose down -vdeletes the volume too and starts the database clean. See the hands-on and named volumes. -
The hands-on
compose.yamlcontains no IP addresses and never declares a network. How does the pipeline find Postgres — and if you renamed thedbservice towarehouse-db, what else in the file would have to change?Show answer
Compose creates one default network for the stack, joins every service to it, and makes each service reachable by its service name as a DNS hostname. Rename the service and the hostname changes with it, so
DB_HOSTmust becomewarehouse-db(and the key underdepends_onmust match the new name). See Service name = hostname. -
Someone simplifies the pipeline service to a plain
depends_on: [db]. The stack now fails with "connection refused" on some runs and works on others. What is the race, and why did the original version not have it?Show answer
A Postgres container counts as started within milliseconds but is not ready to accept connections for a second or two; plain
depends_ononly orders the starts, so the script sometimes connects too early. The original paired apg_isreadyhealthcheck withcondition: service_healthy, which makes Compose wait until the database is actually usable. See Why the healthcheck matters. -
A nightly job runs
ghcr.io/yourname/mypipeline:latest. It worked yesterday, fails today, and nobody touched the job's configuration. What do you suspect first, and what change prevents a repeat?Show answer
That
:latestmoved. A tag is a mutable label — whoever pushed last owns it — so the job may be running different bytes than yesterday even though "nothing changed". Deploy explicit version tags (:v1, a date) so the image can only change when someone changes the tag on purpose. See Image registries. -
A colleague wants every image in the repo converted to a multi-stage build. For this chapter's pure-Python pipeline, is that worth doing? What decides it?
Show answer
What decides it is how much build-time tooling the running app does not need. Here the builder and runtime stages share the same slim base and the dependencies are copied across whole, so the saving is modest and the extra Dockerfile complexity is a fair objection. Multi-stage clearly wins when the build needs compilers or toolchains — that is where images shrink from gigabytes to tens of megabytes and the attack surface narrows. See Multi-stage builds.
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.
Takeaway
- An image is a frozen read-only template and a container is a disposable instance of it, so removing the container leaves the image cached.
- Every Dockerfile instruction is a cached layer, and a change invalidates that layer and every later one, so install dependencies before copying code that changes daily.
CMDis a default that a command afterdocker run imagereplaces, andENTRYPOINTis fixed withCMDsupplying default arguments.- A multi-stage build compiles in one stage and ships only the result in a clean final stage, which shrinks the image and its attack surface.
- Compose puts every service on one network where each is reachable by its service name as a hostname.
- A container that has started is not necessarily ready, so use a health check with
condition: service_healthyor the pipeline races ahead and hits connection refused. :latestis a moving label, so tag with explicit versions when a deploy must be reproducible.
You can build images and join services by name, and the next chapter scales that up into a real local data platform. → Wiring the Mini-Platform
Further reading
- Docker: Building best practices — Docker docs, accessed 2026. Layer ordering, base images,
.dockerignoreand multi-stage builds in one place. - Docker: Build cache — Docker docs, accessed 2026. How layer caching and invalidation work.
- Docker: Multi-stage builds — Docker docs, accessed 2026. Copying artifacts between stages.
- Docker: Networking in Compose — Docker docs, accessed 2026. The default network and service-name DNS.
- Docker: Control startup order in Compose — Docker docs, accessed 2026. Why
depends_onalone does not wait for readiness, and how health checks fix it.