Dockerfiles
The images & layers page explained what an image is. A Dockerfile is how you describe building one — in plain text, checked into version control, run the same way by every developer and every CI pipeline. That last clause is the whole point. The Dockerfile replaces the wiki page of setup steps and the ops engineer’s tribal knowledge with a single executable recipe. The manual, error-prone “configure the box by hand” step becomes code, and code can be reviewed, diffed, and re-run identically forever.
The instructions, and what each one does
Section titled “The instructions, and what each one does”A Dockerfile is a list of instructions, processed top to bottom. Each one (mostly) adds a layer. The handful you’ll use constantly:
| Instruction | What it does |
|---|---|
FROM | The base image — your bottom layer and starting filesystem. |
RUN | Execute a command at build time; the result is baked into a layer (e.g. install packages). |
COPY | Copy files from the build context into the image. |
WORKDIR | Set the working directory for later instructions and at run time. |
ENV | Set an environment variable baked into the image. |
EXPOSE | Document which port the app listens on (metadata; doesn’t publish it). |
USER | Switch to a non-root user for subsequent steps and at run time. |
CMD | The default command at run time — overridable on the command line. |
ENTRYPOINT | The fixed executable at run time; arguments append to it. |
The split that trips people up is build time vs run time. RUN happens once, when you build the
image, and its output is frozen into a layer. CMD/ENTRYPOINT happen every time you start a
container from that image. RUN npm install builds the app; CMD ["node","server.js"] runs it.
CMD vs ENTRYPOINT
Section titled “CMD vs ENTRYPOINT”Both set what runs when the container starts, but they combine differently:
ENTRYPOINTis the executable that always runs. Think of it as the fixed verb.CMDsupplies default arguments — and if there’s noENTRYPOINT, it is the command. It’s whatever the user can easily override.
ENTRYPOINT ["python", "app.py"]CMD ["--port", "8080"]# `docker run img` → python app.py --port 8080# `docker run img --port 9090`→ python app.py --port 9090 (CMD overridden)A common, safe default for simple apps is to skip ENTRYPOINT and just write
CMD ["node", "server.js"]. Always prefer the exec form — the JSON-array ["node","server.js"] —
over the shell form node server.js. The exec form runs your process directly as PID 1, so it receives
signals (like the SIGTERM Kubernetes sends to stop it) and shuts down cleanly. The shell form wraps
it in /bin/sh, which swallows those signals — a classic cause of containers that won’t stop gracefully.
The build context
Section titled “The build context”When you run docker build ., that . is the build context — the directory whose contents are
sent to the builder. COPY can only copy from inside this context. Two things follow:
- A huge context (your
node_modules,.git, build artifacts) is slow to send and bloats your image. - You control what’s in the context with a
.dockerignorefile — same idea as.gitignore.
.gitnode_modules*.log.env # never bake secrets into an imagedistMulti-stage builds: build heavy, ship light
Section titled “Multi-stage builds: build heavy, ship light”Here’s a tension. To build an app you often need a heavy toolchain — compilers, dev dependencies, build tools. To run it you need almost none of that. If you build in one image, all that build junk ships to production: a bigger image, a bigger attack surface, slower deploys.
A multi-stage build solves this with multiple FROM lines. Each FROM starts a new stage; you do
the heavy work in an early stage and COPY --from= only the finished artifact into a clean, minimal
final stage. Everything from the build stage that you didn’t copy is thrown away.
# Stage 1: build — has the full toolchainFROM golang:1.22 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod download # cached unless deps changeCOPY . .RUN go build -o /app/server ./cmd/server
# Stage 2: runtime — tiny, no compiler, no source codeFROM gcr.io/distroless/base-debian12COPY --from=build /app/server /serverUSER nonrootEXPOSE 8080ENTRYPOINT ["/server"]The final image contains a single compiled binary on a distroless base — no Go compiler, no source, often just a few megabytes. The build stage’s bulk never leaves your machine. This is the standard pattern for compiled languages, and a strong one for interpreted ones too (build assets / install prod-only deps in one stage, copy them into a slim runtime).
Best practices that compound
Section titled “Best practices that compound”Each of these removes a manual failure point:
- Pin versions.
FROM python:3.12.4-slim, notpython:latest. An unpinned base means your build’s result depends on when you ran it — the opposite of reproducible. (The registries page makes the case againstlatestin full.) - Order layers by change frequency. Manifests and dependency installs early, source code late, so edits hit the cache. (The layer-caching rule.)
- Use
.dockerignore. Small context, fast builds, no accidental secrets. - Run as a non-root user. Add a
USERline. By default a container’s process runs as root inside, and if it escapes its namespace, root inside is closer to root outside than you want. A non-root user shrinks the blast radius for free. - One concern per container. A container should run one main process, not a web server and a database and a cron daemon. It keeps images small, logs clean, and scaling independent.
- Combine related
RUNsteps to avoid leftover layers (e.g.apt-get update && apt-get install …in oneRUN, cleaning the package cache in the same line).
The thread
Section titled “The thread”A Dockerfile is the recurring theme of this book in its purest form. The manual, error-prone step it removes is “set up the environment by hand, hope you remember every command, hope the next person runs them the same way.” It replaces that with a reviewed, version-controlled, byte-for-byte repeatable recipe — and multi-stage builds, pinned versions, and a non-root user mean the thing it produces is small, reproducible, and minimally exposed. That’s how a good Dockerfile makes production safer: the build is no longer an act of memory, it’s an artifact of code.
The architect’s lens
Section titled “The architect’s lens”A Dockerfile is a choice about how your image gets built — answer the five questions before you write one:
- Why does it exist? Because building an image otherwise means a wiki page of setup steps and tribal knowledge, run slightly differently by every person and pipeline. The Dockerfile turns that into a plain-text, version-controlled recipe run identically by every developer and CI job.
- What problem does it solve? Environment assembly drift — “configure the box by hand, hope you remember every command.” It makes the build an artifact of code that can be reviewed, diffed, and re-run byte-for-byte, and the layer model makes the result cacheable.
- What are the trade-offs? Power comes with footguns: the shell form of
CMDswallows theSIGTERMKubernetes sends (no graceful shutdown), everyCOPY/ENVbakes its bytes into a readable layer forever — so a secret survives a laterRUN rm(see Config & Secrets) — and a careless layer order or a fat build context turns a 2-second rebuild into a 5-minute one. - When should I avoid it? Rarely for the recipe itself, but skip the heavy single-stage build when you can multi-stage — ship the compiled binary on a distroless base, not the whole toolchain — and never bake build-time secrets in instead of injecting them at run time.
- What breaks if I remove it? The build reverts to an act of memory: no reproducibility, no review, no diff. Pin nothing and your image depends on when you built it; ignore the exec form and your containers won’t stop cleanly; run as root and a namespace escape lands closer to host root than you want.
Check your understanding
Section titled “Check your understanding”- Explain the difference between build time and run time, and name an instruction that happens at each.
- Why prefer the exec form
["node","server.js"]over the shell form? What breaks with the shell form? - What is the build context, and what two problems does a
.dockerignoresolve? - Why can’t you safely “delete” a secret you copied into an earlier layer? Where should secrets go instead?
- Describe a multi-stage build in one sentence, and explain what it keeps out of the final image and why that’s both smaller and safer.
Show answers
- Build time is when the image is assembled and the result frozen into a layer; run time is each
time a container starts from that image.
RUN(e.g.RUN npm install) happens at build time;CMD/ENTRYPOINThappen at run time. - The exec form runs your process directly as PID 1, so it receives signals like the
SIGTERMKubernetes sends and shuts down cleanly. The shell form wraps it in/bin/sh, which swallows those signals — so the container won’t stop gracefully. - The build context is the directory sent to the builder (the
.indocker build .);COPYcan only pull from inside it. A.dockerignoresolves two problems: it keeps a huge context (.git,node_modules) from slowing the build and bloating the image, and it prevents accidentally copying secrets like.env. - Because layers are immutable and readable by anyone who can pull the image — deleting the file in a later instruction leaves the earlier layer (with the secret) intact. Inject secrets at run time instead: environment variables, mounted files, or a secrets manager (see Config & Secrets).
- A multi-stage build uses multiple
FROMstages to do heavy work (compilers, dev dependencies) in an early stage andCOPY --from=only the finished artifact into a clean, minimal final stage. It keeps the build toolchain and source out of the final image — smaller because all that bulk is discarded, and safer because there’s far less attack surface (often no compiler or shell).