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:
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
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:
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.
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.
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"]
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.
docker history my-app:1.0 # see every layer, its size, and the instruction that created it
docker image inspect my-app:1.0
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.
# 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.
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:
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).
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
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
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:
- 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.
- Add a
.dockerignoreexcluding at minimum your dependency folder and any local env files. - Build it, run it, and confirm it's reachable at
localhoston your published port. - Run
docker history <your-image>and identify which layer is the largest — is it one you expected?
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:
- Rewrite your Dockerfile with
COPY . .immediately afterWORKDIR, before installing dependencies (the "wrong order" pattern from Section 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. - 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.
- 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.
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?
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?
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?
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?
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.