5. The Dockerfile
Docker · 5. The DockerfileThis is the most important chapter in this guide. Almost every Docker problem people actually hit — slow builds, huge images, leaked secrets, containers that will not stop, permission errors on volumes — is written into a Dockerfile, usually in one line, usually copied from somewhere.
A Dockerfile is a sequence of instructions. Each one is applied to the result of the previous one. That is the whole model. The difficulty is entirely in the details, so this chapter is a detailed reference you can come back to.
The shape of a good one
Section titled “The shape of a good one”Here is a Dockerfile that does most things right. The rest of the chapter explains every line.
# syntax=docker/dockerfile:1
FROM node:20-slim AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-slimENV NODE_ENV=productionWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=dev && npm cache clean --forceCOPY --from=build /app/dist ./distUSER nodeEXPOSE 3000ENTRYPOINT ["node", "dist/server.js"]Two stages, dependencies copied before source, a non-root user, an exec-form entrypoint. Under 15 lines and it beats most Dockerfiles in production.
Which instructions create layers
Section titled “Which instructions create layers”This is the first thing to get straight, because it drives image size and cache behaviour.
CREATE A LAYER METADATA ONLY (free, no layer)
RUN FROM (starts from existing layers) COPY ENV ADD ARG WORKDIR USER EXPOSE LABEL CMD ENTRYPOINT VOLUME HEALTHCHECK STOPSIGNAL SHELLOnly three instructions add bytes. Everything else writes a line into the config blob from chapter 3. So ENV, WORKDIR and LABEL are free — use them freely — and every RUN, COPY and ADD is a decision.
FROM — where you start
Section titled “FROM — where you start”FROM debian:12-slimFROM python:3.12-slim AS build ← named stage (chapter 7)FROM gcr.io/distroless/static:nonroot ← no shell, no package managerFROM scratch ← literally emptyThe base image choice determines most of your final size and most of your CVE count. In rough order:
scratch 0 MB nothing at all. For static Go/Rust binaries. distroless ~2-20 MB the runtime and its libs, no shell, no apt. alpine ~7 MB musl libc, busybox, apk. *-slim ~30-80MB Debian without docs, man pages, extras. debian/ubuntu ~75-120 the full thing.A warning about Alpine. It is tempting because it is tiny, and it is genuinely great for Go and static binaries. But it uses musl rather than glibc, and that is a real behavioural difference, not just a size one:
- Python — many wheels are built for glibc (
manylinux). On Alpine,pip installfalls back to compiling from source, so your build goes from 40 seconds to 12 minutes and needs a compiler toolchain in the image. - Performance — musl’s memory allocator is measurably slower than glibc’s for allocation-heavy workloads. There are well-documented cases of Python and Node services running noticeably slower on Alpine for no other reason.
- DNS — musl’s resolver historically did not read
searchdomains andoptionsthe same way, and did not fall back from UDP to TCP for large responses. This has improved but still produces confusing intermittent resolution failures.
For compiled static binaries, use scratch or distroless. For interpreted languages, -slim is usually the better default than Alpine, and it is very often smaller in the end because you do not need a build toolchain.
Pin the base image, and pin it well:
FROM python:3 ← changes minor versions under you FROM python:3.12 ← changes patch versions FROM python:3.12.7-slim ← good FROM python:3.12.7-slim@sha256:… ← reproducible (chapter 3)RUN — the one that costs you
Section titled “RUN — the one that costs you”RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl \ && rm -rf /var/lib/apt/lists/*Every part of that line is doing work:
update and install must be in the same RUN. Split across two, the cached update layer goes stale while the install layer re-runs, and you install packages from a months-old index — the classic “cache busting” bug that produces 404 Not Found from apt on an otherwise unchanged Dockerfile.
--no-install-recommends typically halves what apt drags in.
rm -rf /var/lib/apt/lists/* in the same RUN. In a later RUN, chapter 3’s whiteout rule applies and the ~40 MB package index is still in the image.
Chain related commands; do not chain unrelated ones. Each RUN is a cache checkpoint, so this is a genuine trade-off:
too many layers too few layers
RUN apt-get update RUN apt-get update && \ RUN apt-get install -y curl apt-get install -y curl && \ RUN rm -rf /var/lib/apt/lists rm -rf /var/lib/apt/lists && \ RUN pip install -r req.txt pip install -r req.txt
3 broken caches, bloated image any change re-runs everything, including a 4-minute pip installThe rule that works: one RUN per logical unit of work — system packages in one, language dependencies in another, build steps in a third.
Shell form vs exec form
Section titled “Shell form vs exec form” RUN apt-get update SHELL form: runs /bin/sh -c "…" RUN ["apt-get", "update"] EXEC form: runs the binary directlyFor RUN this rarely matters (shell form is standard, and you want shell features like && and *). For CMD and ENTRYPOINT it matters enormously, which is the next section.
ENTRYPOINT and CMD — the table
Section titled “ENTRYPOINT and CMD — the table”This is the most confused pair in Docker, so here is the actual rule:
ENTRYPOINT is the command. CMD is the default arguments to it. Anything you type after
docker run imagereplaces CMD, never ENTRYPOINT.
Dockerfile docker run img runs
CMD ["nginx","-g","daemon off;"] (nothing) nginx -g daemon off; CMD ["nginx","-g","daemon off;"] bash bash ENTRYPOINT ["nginx"] (nothing) nginx ENTRYPOINT ["nginx"] -v nginx -v ENTRYPOINT ["nginx"] CMD ["-g","…"] (nothing) nginx -g … ENTRYPOINT ["nginx"] CMD ["-g","…"] -v nginx -v ENTRYPOINT ["nginx"] bash nginx bash ← errorThat last row is the one that surprises people: with an ENTRYPOINT set, docker run img bash does not give you a shell — it passes bash as an argument to nginx. You need docker run --entrypoint bash img.
Which to use:
- A tool (a CLI, a linter, a migration runner) — use
ENTRYPOINT, sodocker run mytool --helpworks naturally. - A service — either works.
ENTRYPOINTis slightly safer because arguments cannot accidentally replace the command. - A base image others will extend — prefer
CMD, so they can override cleanly.
Exec form is not optional
Section titled “Exec form is not optional” ENTRYPOINT node server.js ← SHELL form. Do not use. ENTRYPOINT ["node", "server.js"] ← EXEC form. Use this.The difference is not style. Shell form becomes:
/bin/sh -c "node server.js"so /bin/sh is PID 1, and your application is a child of it. Now recall chapter 2: docker stop sends SIGTERM to PID 1. That is sh. And sh does not forward signals to its children.
docker stop → SIGTERM to /bin/sh → sh ignores it → 10 seconds pass → SIGKILL to everything → your app is killed mid-request, connections cut, no graceful shutdown, exit code 137Every deploy takes ten seconds longer than it should and drops in-flight requests, and nothing anywhere says why. Use exec form for ENTRYPOINT and CMD, always. Chapter 8 covers the remaining cases where you genuinely need a shell — the answer is exec "$@" in an entrypoint script.
One consequence of exec form: there is no shell, so $VARIABLE is not expanded.
CMD ["node", "server.js", "--port=$PORT"] ← passes the literal "$PORT"If you need variable expansion, use an entrypoint script that ends with exec, or let the application read the environment itself — which it should anyway.
COPY vs ADD
Section titled “COPY vs ADD” COPY src dest copies files. That is all it does. ADD src dest copies files, AND auto-extracts local tarballs, AND can fetch URLs.Use COPY. ADD’s extra behaviours are surprises: a file named something.tar.gz is silently unpacked instead of copied, and URL fetching produces an un-cacheable, unverified download inside your image. The one legitimate ADD use is unpacking a local tarball you control, and even then people write ADD when they meant COPY far more often than the reverse.
Useful flags:
COPY --chown=node:node . /app set ownership without a RUN chown COPY --from=build /app/dist ./dist from another stage (chapter 7) COPY --chmod=755 entrypoint.sh / set the mode inline--chown matters more than it looks. Without it, files land as root, and a container running as a non-root USER cannot write to its own directories. Doing it with RUN chown -R afterwards duplicates every file into a new layer — a 200 MB COPY followed by RUN chown -R produces a 400 MB image.
The ordering rule
Section titled “The ordering rule”Everything about build speed reduces to one sentence:
Order instructions from least likely to change to most likely to change.
Chapter 6 traces exactly why, but the mechanism in short: the cache is checked instruction by instruction from the top, and the first miss invalidates every instruction after it.
WRONG RIGHT
COPY . . COPY package*.json ./ RUN npm ci RUN npm ci COPY . . COPY . .
any source change changes a source change misses only COPY . ., so npm ci re-runs the last COPY. npm ci is cached. → 4 minutes, every build → 8 secondsThis one pattern — copy the dependency manifest, install, then copy the source — applies to every ecosystem: package.json for Node, requirements.txt or pyproject.toml for Python, go.mod and go.sum for Go, Cargo.toml for Rust, pom.xml for Maven, Gemfile for Ruby. It is the single highest-value change most Dockerfiles can make.
ARG vs ENV
Section titled “ARG vs ENV”They look similar and behave completely differently.
ARG build-time only. NOT present in the running container. Set with --build-arg. Scoped: an ARG before the first FROM is only visible to FROM lines unless re-declared.
ENV build AND run. Baked into the image config. Visible to the process, to `docker inspect`, to anyone who pulls the image.ARG NODE_VERSION=20 ← before FROM: usable in FROMFROM node:${NODE_VERSION}-slim
ARG BUILD_ID ← re-declared: now usable in RUNENV NODE_ENV=production ← present at runtimeRUN echo "built from ${BUILD_ID}" > /app/build-infoNeither is safe for secrets. ARG is not in the runtime environment, which fools people — but docker history shows every build arg, and any layer created while the value was in the environment records it. If a token is passed as --build-arg, treat it as published. Chapter 7’s RUN --mount=type=secret is the correct mechanism.
USER, WORKDIR, EXPOSE, and the rest
Section titled “USER, WORKDIR, EXPOSE, and the rest”WORKDIR /app creates the dir if needed, and sets cwd for every following RUN/CMD/ENTRYPOINT/COPY. Use it instead of `RUN cd /app` — which does nothing, because each RUN is a separate shell.
USER node every following instruction, and the container itself, runs as this user. Put it AFTER the installs that need root. The user must exist — create it with a RUN adduser if the base has none.
EXPOSE 3000 DOCUMENTATION ONLY. It publishes nothing and opens nothing. Chapter 10. It is read by `docker run -P` and by some tooling.
VOLUME /data declares a mount point. Anonymous volumes get created automatically for it — including on every `docker run`, which quietly accumulates gigabytes. Prefer documenting it and letting users mount explicitly.
HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1 Docker runs this periodically and marks the container healthy/unhealthy. Compose's depends_on: condition: service_healthy uses it. Kubernetes IGNORES it entirely — it has its own probes.
STOPSIGNAL SIGQUIT which signal `docker stop` sends. nginx wants SIGQUIT for graceful shutdown, not SIGTERM.
LABEL org.opencontainers.image.source=https://github.com/org/repo metadata. The `image.source` label is what links a package to its repo on GitHub Container Registry.USER deserves emphasis: containers run as root by default, and that is the wrong default for anything reachable from a network. Chapter 11 covers what it buys you and what it does not.
.dockerignore
Section titled “.dockerignore”Chapter 4 explained that the CLI tars the whole context directory and sends it to the daemon. .dockerignore controls what goes into that tar.
.gitnode_modules__pycache__*.pyc.env.venvdistbuildtargetcoverage*.logDockerfile.dockerignoreREADME.mdThree separate reasons this matters:
Speed. A repository with a 900 MB .git and a 400 MB node_modules sends 1.3 GB over a socket on every build, before a single instruction runs.
Cache. With COPY . ., any change inside the context — including a log file your test run wrote, or a .git object from a fetch — changes the layer’s hash and invalidates everything after it. Builds that “randomly” miss cache are almost always this.
Security. COPY . . with a .env file present copies your credentials into the image. Permanently, per chapter 3.
Excluding node_modules also prevents the subtler bug where a host node_modules built on macOS/arm64 is copied into a Linux/amd64 image, overwriting the correct one from npm ci, and native modules fail at runtime with an error that mentions nothing about architecture.
What to take from this chapter
Section titled “What to take from this chapter”- Only
RUN,COPYandADDcreate layers. Everything else is free metadata — useENV,WORKDIRandLABELliberally. - Order from least-changing to most-changing. Copy the dependency manifest, install, then copy source. The first cache miss invalidates everything after it, so this one pattern is usually the difference between a 4-minute and an 8-second build.
- ENTRYPOINT is the command; CMD is its default arguments;
docker runarguments replace CMD only. With an ENTRYPOINT set,docker run img bashpassesbashas an argument — use--entrypoint. - Always use exec form (
["node","server.js"]). Shell form makes/bin/shPID 1, and sh does not forward SIGTERM, so every stop takes 10 seconds and kills your app mid-request. The cost is no$VARexpansion — handle that in an entrypoint script or in the app. - Use
COPY, notADD.ADDsilently untars and silently downloads. UseCOPY --chownrather than a followingRUN chown -R, which duplicates every file into a new layer. apt-get updateandinstallmust share oneRUN, and the cleanup must be in that sameRUNor the bytes stay in the image.ARGis build-time,ENVis both — and neither is safe for secrets.docker historyshows build args.- Alpine is not automatically the right choice. musl breaks Python wheels, is slower for allocation-heavy work, and has had DNS quirks. Prefer
-slimfor interpreted languages,distroless/scratchfor static binaries. EXPOSEpublishes nothing,VOLUMEcreates anonymous volumes you will forget about, andHEALTHCHECKis ignored by Kubernetes..dockerignoreis not optional. It controls build speed, cache correctness, and whether your.envends up in the image.
Next: one build, traced from docker build . to a manifest on disk — including exactly how the cache decides to hit or miss.