Skip to content

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.

A Dockerfile is a list of instructions, processed top to bottom. Each one (mostly) adds a layer. The handful you’ll use constantly:

InstructionWhat it does
FROMThe base image — your bottom layer and starting filesystem.
RUNExecute a command at build time; the result is baked into a layer (e.g. install packages).
COPYCopy files from the build context into the image.
WORKDIRSet the working directory for later instructions and at run time.
ENVSet an environment variable baked into the image.
EXPOSEDocument which port the app listens on (metadata; doesn’t publish it).
USERSwitch to a non-root user for subsequent steps and at run time.
CMDThe default command at run time — overridable on the command line.
ENTRYPOINTThe 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.

Both set what runs when the container starts, but they combine differently:

  • ENTRYPOINT is the executable that always runs. Think of it as the fixed verb.
  • CMD supplies default arguments — and if there’s no ENTRYPOINT, 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.

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:

  1. A huge context (your node_modules, .git, build artifacts) is slow to send and bloats your image.
  2. You control what’s in the context with a .dockerignore file — same idea as .gitignore.
.dockerignore
.git
node_modules
*.log
.env # never bake secrets into an image
dist

Multi-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 toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached unless deps change
COPY . .
RUN go build -o /app/server ./cmd/server
# Stage 2: runtime — tiny, no compiler, no source code
FROM gcr.io/distroless/base-debian12
COPY --from=build /app/server /server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/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).

Each of these removes a manual failure point:

  • Pin versions. FROM python:3.12.4-slim, not python: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 against latest in 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 USER line. 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 RUN steps to avoid leftover layers (e.g. apt-get update && apt-get install … in one RUN, cleaning the package cache in the same line).

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.

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 CMD swallows the SIGTERM Kubernetes sends (no graceful shutdown), every COPY/ENV bakes its bytes into a readable layer forever — so a secret survives a later RUN 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.
  1. Explain the difference between build time and run time, and name an instruction that happens at each.
  2. Why prefer the exec form ["node","server.js"] over the shell form? What breaks with the shell form?
  3. What is the build context, and what two problems does a .dockerignore solve?
  4. Why can’t you safely “delete” a secret you copied into an earlier layer? Where should secrets go instead?
  5. 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
  1. 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/ENTRYPOINT happen at run time.
  2. The exec form runs your process directly as PID 1, so it receives signals like the SIGTERM Kubernetes sends and shuts down cleanly. The shell form wraps it in /bin/sh, which swallows those signals — so the container won’t stop gracefully.
  3. The build context is the directory sent to the builder (the . in docker build .); COPY can only pull from inside it. A .dockerignore solves 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.
  4. 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).
  5. A multi-stage build uses multiple FROM stages to do heavy work (compilers, dev dependencies) in an early stage and COPY --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).