Week 2: Dockerfiles, Images & Layers

This is the week you stop pulling other people's images and start building your own. A Dockerfile is a small, declarative recipe — but every instruction in it creates a new filesystem layer, and how you order those instructions is the difference between a build that takes ten seconds on a code change and one that reinstalls every dependency from scratch every single time. We'll containerize a real app and get the layer cache working for you instead of against you.

Module 2 of 17 Week 2 of 18 ~3.5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a correct Dockerfile using FROM, RUN, COPY, WORKDIR, EXPOSE, CMD and ENTRYPOINT
  • Order instructions to maximize layer-cache hits on rebuilds
  • Containerize a real application, tag it sensibly, and keep build context clean with .dockerignore

1. Dockerfile Instructions

A Dockerfile is a plain-text list of instructions for building an image, executed top to bottom. Here's a minimal but real one, for a Node.js app:

Dockerfile
FROM node:20-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
what each instruction does
FROM      # the base image everything else builds on top of — always the first instruction
WORKDIR   # sets the working directory for every instruction after it (creates it if missing)
COPY      # copies files from the build context (your machine) into the image
RUN       # executes a command AT BUILD TIME and commits the result as a new layer
EXPOSE    # documentation only — tells humans/tools which port the app listens on
CMD       # the default command to run when the container STARTS (can be overridden)

Build it and run it:

terminal
docker build -t my-app:1.0 .
docker run -d -p 3000:3000 my-app:1.0

The . at the end of docker build is the build context — the directory Docker sends to the daemon so COPY instructions have something to copy from. Everything in that directory gets sent, which is exactly why the next section matters.

.dockerignore

Add a .dockerignore file (same syntax as .gitignore) to exclude node_modules, .git, and build artifacts from the build context. Skipping this bloats every build and can accidentally bake secrets or local-only files into your image.

2. Layers & the Build Cache

Every RUN, COPY and ADD instruction creates a new, immutable filesystem layer stacked on top of the previous one. Docker caches each layer by hashing the instruction and its inputs — if nothing changed, it reuses the cached layer instead of re-executing. The catch: the moment one layer's cache misses, every layer after it is rebuilt too, cache or not.

the wrong order — cache-busts on every code change
FROM node:20-slim
WORKDIR /app
COPY . .                    # <- copies EVERYTHING, including source files that change constantly
RUN npm ci --omit=dev       # <- cache misses on every single code edit, reinstalls all deps
CMD ["node", "server.js"]
the right order — dependencies cached separately from code
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./   # <- only invalidates when dependencies actually change
RUN npm ci --omit=dev                    # <- stays cached across most rebuilds
COPY . .                                 # <- source code copied LAST, changes most often
CMD ["node", "server.js"]

The rule of thumb: put things that change least often at the top, things that change most often at the bottom. Dependency manifests change rarely; application source changes constantly. Getting this order right can turn a two-minute rebuild into a two-second one.

inspecting layers
docker history my-app:1.0     # see every layer, its size, and the instruction that created it
docker image inspect my-app:1.0
Combine RUN commands that must go together

Each RUN is a separate layer, so RUN apt update followed by a separate RUN apt install -y curl can produce a "stale package list" layer that ships with your image forever. Chain related steps with && in one RUN instead: RUN apt update && apt install -y curl && rm -rf /var/lib/apt/lists/*.

3. CMD vs. ENTRYPOINT

These two are consistently confusing, and the difference matters the moment someone runs your image with extra arguments. ENTRYPOINT defines the command that always runs. CMD defines default arguments, which can be overridden at docker run time.

three common patterns
# Pattern 1: CMD only — the whole thing is overridable
CMD ["node", "server.js"]
# docker run my-app          -> runs: node server.js
# docker run my-app bash     -> runs: bash  (completely replaces CMD)

# Pattern 2: ENTRYPOINT only — always runs, extra args get appended
ENTRYPOINT ["node", "server.js"]
# docker run my-app          -> runs: node server.js
# docker run my-app --debug  -> runs: node server.js --debug

# Pattern 3: ENTRYPOINT + CMD — fixed command, overridable DEFAULT arguments
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run my-app             -> runs: node server.js
# docker run my-app worker.js   -> runs: node worker.js

Pattern 3 is the most common in real Dockerfiles — it fixes the runtime/interpreter (node) while letting the caller swap what script runs, which is exactly how you'd support a "web" mode and a "worker" mode from the same image.

Always use the array (exec) form

CMD ["node", "server.js"] runs your process as PID 1 directly. The shell form, CMD node server.js, wraps it in /bin/sh -c, which means signals like SIGTERM go to the shell, not your app — your process may not shut down gracefully. Prefer the array form unless you specifically need shell features.

4. Containerizing a Real App

Let's put it together for a Python/Flask app, a slightly different ecosystem than the Node example above, to see the same principles apply everywhere:

Dockerfile — Python/Flask
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000
ENV FLASK_APP=app.py
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]

Two things worth calling out that trip people up their first time: --host=0.0.0.0 is required — a server that only binds to 127.0.0.1 (localhost) inside the container is unreachable from outside it, even with a published port, because "localhost" inside a container means the container itself, not your host machine. And ENV sets an environment variable baked into the image (and overridable at run time with docker run -e).

tagging with intent
docker build -t myrepo/flask-app:1.2.0 .
docker build -t myrepo/flask-app:latest .   # only ever point "latest" at something you mean it to
docker tag myrepo/flask-app:1.2.0 myrepo/flask-app:stable
Never deploy "latest" to production

It's a mutable, moving pointer — the "latest" tag on Monday may not be the same image as "latest" on Tuesday, which makes rollbacks and reproducibility impossible. Real deployments pin an immutable version or, better, an image digest. You'll see this enforced again in Week 8's Deployments.

5. Hands-on Exercise

Hands-on

Containerize a small app, then fix a deliberately bad build order

Write a real Dockerfile from scratch, then diagnose and fix a common performance mistake using build timing as evidence.

Part 1 — Build it:

  1. Pick any small app you have (or write a 10-line "Hello World" HTTP server in a language of your choice). Write a Dockerfile for it with FROM, WORKDIR, dependency install, COPY, EXPOSE and CMD.
  2. Add a .dockerignore excluding at minimum your dependency folder and any local env files.
  3. Build it, run it, and confirm it's reachable at localhost on your published port.
  4. Run docker history <your-image> and identify which layer is the largest — is it one you expected?
Hint

If your server won't respond from outside the container, double-check it's bound to 0.0.0.0, not 127.0.0.1 or localhost — this is the single most common "it built fine but I can't reach it" bug.

Part 2 — Break it, then fix it, and measure the difference:

  1. Rewrite your Dockerfile with COPY . . immediately after WORKDIR, before installing dependencies (the "wrong order" pattern from Section 2).
  2. Build it once (time docker build -t bad-order .), then make a trivial one-line change to your source code (not your dependency file) and rebuild, timing it again.
  3. Now fix the order — copy the dependency manifest first, install, then copy source last — rebuild once to warm the cache, make the same trivial source change, and time the rebuild again.
  4. Write down the two rebuild times and explain, in your own words, exactly which layer's cache miss caused the slow version to reinstall dependencies unnecessarily.
Hint

Use the Unix time command (or PowerShell's Measure-Command) to wrap the build command — you want real numbers, not a guess, to see how dramatic the difference is on a project with real dependencies.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should COPY package.json / RUN install come before COPY . . in a Dockerfile?

Docker's layer cache invalidates a layer and everything after it once its inputs change. Dependency manifests change rarely, so installing them in an early, separately-cached layer means the (usually slow) install step is skipped on most rebuilds — only source-code changes (copied last) trigger a fast final layer rebuild.

Q2

What's the practical difference between CMD and ENTRYPOINT?

ENTRYPOINT is the command that always runs and is hard to override. CMD supplies default arguments (or a default command, if ENTRYPOINT isn't set) that CAN be overridden by arguments passed to docker run. Combining both — a fixed ENTRYPOINT with an overridable CMD — is the most common real-world pattern.

Q3

Why does a Flask/Express server need to bind to 0.0.0.0 instead of 127.0.0.1 inside a container?

Inside a container, "localhost"/127.0.0.1 refers to the container's own loopback interface, not the host machine. A server bound only to 127.0.0.1 refuses connections arriving from outside the container — even with a port published — because they don't come in over the loopback interface. Binding to 0.0.0.0 listens on all interfaces, including the one Docker's port-publishing uses.

Q4

Why is deploying an image tagged "latest" to production considered bad practice?

"latest" is a mutable, moving tag — it can point to a different underlying image at different times, which breaks reproducibility (you can't be sure two deployments ran the same code) and makes rollbacks unreliable (there's no fixed "previous latest" to roll back to). Pinning an explicit version or image digest avoids this entirely.