Appearance
13.6.5 — The Dockerfile, Instruction by Instruction
A Node service's image is 1.4 GB. Every deploy pushes and pulls it, every scaling event waits for it, and the vulnerability scanner reports two hundred findings — almost all of them in packages the application never calls.
The application is 4 MB of JavaScript.
Nothing about the code is wrong. The Dockerfile ships a compiler toolchain, a package manager cache, the .git directory, development dependencies and a full Debian userland into production. This page fixes that, and covers every instruction the format has, because each one leaves a trace in the layer stack from Chapter 13.6.4 and a few of them leave traces you did not intend.
1. The eighteen instructions
| Instruction | What it does | Creates a layer? |
|---|---|---|
FROM | Starts a stage from a base image | The base layers |
RUN | Runs a command at build time | Yes |
COPY | Copies files in from the build context | Yes |
ADD | COPY plus URL fetch and auto-extract | Yes |
CMD | Default command or arguments | No |
ENTRYPOINT | The command the image is | No |
WORKDIR | Sets the working directory | No |
ENV | Environment variable, build and run | No |
ARG | Build-time variable only | No |
EXPOSE | Documents a port | No |
VOLUME | Declares a mount point | No |
USER | Sets the user for later steps and at run | No |
LABEL | Metadata key/value | No |
HEALTHCHECK | How to test the container is healthy | No |
SHELL | Changes the shell used by shell form | No |
STOPSIGNAL | Signal sent to stop the container | No |
ONBUILD | A trigger that fires in a child build | No |
MAINTAINER | Deprecated — use a LABEL | No |
Only three instructions add filesystem content: RUN, COPY and ADD. Everything else writes metadata into the config blob. That single fact tells you where all your image size comes from and where to look first.
2. FROM — where everything starts
dockerfile
FROM node:22-slimThe base image's layers become your bottom layers. Choosing it decides your size floor, your libc, your package manager, your patching cadence and most of your vulnerability report.
Choosing a base
| Base | Size | What you are trading |
|---|---|---|
node:22 (Debian) | ~1.1 GB | Everything present, including compilers |
node:22-slim | ~200 MB | The safe default |
node:22-alpine | ~130 MB | musl libc — native modules and DNS can differ |
gcr.io/distroless/nodejs22 | ~110 MB | No shell, no package manager |
scratch | 0 | Static binaries only (Go, Rust) |
Alpine's caveat costs people real days, so it is worth stating plainly. Alpine uses musl as its C library rather than the glibc every other distribution uses. They implement the same standard differently. Native modules compiled against glibc may fail to build or behave subtly differently; DNS resolution historically diverged in ways that produced intermittent failures under load. Slim by default; Alpine once you have tested your actual dependency tree on it.
Distroless is the security answer. It contains your language runtime and its libraries and nothing else — no shell, no apt, no curl. An attacker who achieves code execution finds no shell to run and no package manager to fetch tools with. The cost is that you cannot docker exec into it to debug, and the replacement is an ephemeral debug container that attaches alongside (Chapter 13.6.14).
Pin it
dockerfile
FROM node:22-slim@sha256:0e1b3f6a2c...Chapter 13.6.4 explained why: a tag is a mutable pointer, so a build that worked last month can produce different software today. Pin by digest for reproducibility, and run an automated update tool so pinning does not become "never patched".
Multi-stage: several FROMs in one file
This is the single largest size win available and it is four extra lines.
dockerfile
# ---- build stage ----
FROM node:22 AS build # (1)
WORKDIR /app
COPY package*.json ./
RUN npm ci # (2)
COPY . .
RUN npm run build # (3)
# ---- runtime stage ----
FROM node:22-slim AS runtime # (4)
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force # (5)
COPY --from=build /app/dist ./dist # (6)
USER node
CMD ["node", "dist/server.js"](1) A named stage. The full image, with compilers and headers, because building needs them.
(2) All dependencies, including development ones — TypeScript, the bundler, the type definitions.
(3) Compiles TypeScript to JavaScript in /app/dist.
(4) A brand new stage from a clean base. Nothing from the build stage exists here unless explicitly copied.
(5) Production dependencies only, and the package manager cache removed in the same RUN, so it never lands in a finished layer.
(6) The one line that does the work. Only the compiled output crosses the boundary. The compilers, the source, the development dependencies and the .git directory are all left behind in a stage that is never pushed.
Result: 1.4 GB becomes about 90 MB, and the vulnerability count falls by most of itself because the packages generating the findings are simply not present.
Other things --from can do:
dockerfile
COPY --from=build /app/dist ./dist # from a named stage
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/ # (1)(1) Copy directly from a published image you never run. Useful for pulling a single binary or config file out of a distribution image.
3. RUN — where size and cache are won or lost
dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*Four separate lessons in one line.
update and install must be in the same RUN. Split them and the update layer gets cached. Weeks later your build reuses a stale package index and installs old versions, or fails because the indexed version no longer exists on the mirror. This failure is intermittent and confusing, and it has a name — cache staleness — and one fix: keep them together.
--no-install-recommends stops the package manager pulling in suggested extras. On Debian this routinely saves 100 MB and removes packages you never asked for.
rm -rf /var/lib/apt/lists/* in the same RUN. The package index is ~40 MB of files you will never use again. Deleting it in a later RUN saves nothing at all (Chapter 13.6.4).
Chain related commands, but do not chain everything. One giant RUN is one cache entry — change anything in it and the whole thing re-runs. Group by how often things change, which is the same principle as instruction ordering.
Shell form and exec form
dockerfile
RUN npm ci # shell form: /bin/sh -c "npm ci"
RUN ["npm", "ci"] # exec form: executed directlyShell form is normal for RUN, because you usually want &&, pipes and variable expansion. For CMD and ENTRYPOINT the choice is critical and section 5 explains why.
Pipes fail silently
dockerfile
RUN curl -fsSL https://example.com/install.sh | sh # (1)
RUN set -o pipefail && curl -fsSL ... | sh # (2)(1) sh returns the exit code of the last command in a pipe. If curl fails with a 404, sh runs the error page as a script, does nothing, exits 0, and your build succeeds with a broken image.
(2) pipefail makes the pipeline fail if any stage fails. Without it, this is a class of bug that ships to production looking healthy.
BuildKit mounts
dockerfile
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev # (1)
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci # (2)
RUN --mount=type=bind,from=build,source=/app,target=/src cp /src/x . # (3)(1) A cache mount. The package manager's download cache lives outside the layer and persists between builds. Faster builds, and nothing added to the image.
(2) A secret mount. Supplied with docker build --secret id=npmrc,src=$HOME/.npmrc, available at that path only while the instruction runs, and present in no layer afterwards. This is the only correct way to use a credential during a build.
(3) A bind mount from another stage, when you want to read files without copying them into a layer.
4. COPY, ADD and the build context
dockerfile
COPY package*.json ./ # (1)
COPY --chown=node:node . . # (2)
COPY --from=build /app/dist ./ # (3)(1) Copies from the build context into the image. (2) Sets ownership as it copies. Doing it afterwards with RUN chown -R copies every file again into a new layer — a classic way to double an image's size. (3) From another stage.
ADD does everything COPY does, plus two things you usually do not want: it fetches URLs, and it automatically extracts local tar archives. The auto-extract is surprising behaviour that has caused real bugs. Use COPY unless you specifically want extraction; for a URL, use curl in a RUN so you can verify a checksum and delete the archive in the same layer.
The build context
Before any instruction runs, the client packs the build directory and sends it to the builder. With no .dockerignore, that means node_modules, .git, dist, log files and any local .env.
This costs you twice. Every build uploads hundreds of megabytes. And a COPY . . then puts your .git history and your local secrets into the image, where anyone who can pull it can read them. A repository's git history frequently contains credentials that were removed from the working tree but never from the history.
.git
.github
node_modules
dist
coverage
*.log
.env*
Dockerfile
README.mdA .dockerignore is a two-line fix for a performance problem and a security problem at once, and it is the single most commonly missing file in real repositories.
Instruction order decides build time
dockerfile
FROM node:22-slim
WORKDIR /app
COPY package*.json ./ # (1)
RUN npm ci --omit=dev # (2)
COPY . . # (3)
CMD ["node", "server.js"](1) Copy only the manifests — files that change rarely. (2) Install dependencies. This layer stays cached until a manifest changes.(3) Copy the source, which changes on every commit.
Reverse (1) and (3) — a single COPY . . before the install — and every source edit reinstalls every dependency. That one ordering decision is routinely the difference between a fifteen-second build and a four-minute one, and Chapter 13.6.4 showed it also decides how many bytes each deploy pushes and pulls.
The rule, stated once: least-frequently-changing first.
And know what invalidates the cache. For COPY and ADD, the content of the copied files. For everything else, the text of the instruction itself. File modification times are ignored, but permission bits are not — which is why a checkout in CI that produces different file modes can miss a cache that a local build hits.
5. CMD and ENTRYPOINT
Both describe what runs. They are not interchangeable, and the difference between the two forms causes a specific, common production bug.
dockerfile
CMD ["node", "server.js"] # exec form — node becomes PID 1
CMD node server.js # shell form — /bin/sh -c "node server.js"With the shell form, sh is PID 1 and your application is its child. Chapter 13.6.2 explained that PID 1 has no default signal handlers — and sh does not forward signals to children. So when the orchestrator sends SIGTERM, sh ignores it, your application never hears about it, and thirty seconds later SIGKILL arrives and drops every in-flight request.
This is the cause of most "my container always takes exactly 30 seconds to stop" reports. Two fixes, and you want both: use exec form, and handle SIGTERM in your application to shut down gracefully.
ENTRYPOINT versus CMD:
dockerfile
ENTRYPOINT ["node", "server.js"] # (1)
CMD ["--port", "3000"] # (2)(1) The command. docker run img extra appends to it, so the image behaves like a program. (2) Default arguments. docker run img --port 4000 replaces them entirely.
Set ENTRYPOINT when the image is a program and leave CMD overridable. Set only CMD when you want docker run img bash to give you a shell for debugging.
The entrypoint script pattern, which is how most real images handle setup:
bash
#!/bin/sh
set -e
if [ -n "$RUN_MIGRATIONS" ]; then
node dist/migrate.js
fi
exec "$@" # (1)(1) exec replaces the shell with your process, so your process becomes PID 1 and receives signals. Without exec, the script stays as PID 1 and swallows every signal — the same bug as shell form, wearing a different hat. The exec on the last line of an entrypoint script is not optional.
6. The metadata instructions, one by one
WORKDIR /app — sets the directory for later instructions and at runtime. It creates the directory if missing. Use it instead of RUN cd, which does nothing lasting because each RUN is a fresh shell.
ENV NODE_ENV=production — a variable available at build time and in the running container, and inherited by every child process. Never put a secret here. It is in the image config, readable by anyone who can pull it, and visible in the environment of every process in the container.
ARG NODE_VERSION=22 — build-time only, not present at runtime, settable with --build-arg. Also never a secret: build arguments are recorded in the image history and readable with docker history. A token passed as a build argument is a leaked token. Use a BuildKit secret mount instead.
One ARG subtlety that catches people: an ARG declared before the first FROM is available to the FROM lines but not inside a stage unless re-declared:
dockerfile
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG NODE_VERSION # re-declare to use it inside the stage
RUN echo "built on ${NODE_VERSION}"EXPOSE 3000 — pure documentation. It publishes nothing and opens nothing. It records the port in the metadata so that tools and readers know, and docker run -P uses it to pick random host ports. Every other publishing mechanism ignores it.
VOLUME /data — declares a path whose contents should live outside the container's writable layer. Two behaviours surprise people: if you do not supply a volume, Docker creates an anonymous one, and those accumulate invisibly; and once a path is declared a volume, later RUN instructions writing to it have no effect on the image, because the writes go to the volume, not the layer. Prefer declaring volumes when you run rather than in the Dockerfile.
USER node — everything after this runs as that user, including at runtime.
dockerfile
RUN groupadd -r app && useradd -r -g app -u 10001 app # (1)
USER 10001 # (2)(1) Create a system user with an explicit numeric ID. (2) Use the number, not the name. Kubernetes' runAsNonRoot check reads the numeric UID from the image config and cannot resolve a name, so a container declaring USER app fails that policy check even though it is not root.
Order matters: USER before the last COPY means the copied files may be unwritable by that user. Copy with --chown, then switch user.
LABEL — metadata. The OpenContainers convention is the one to use, because tools read it:
dockerfile
LABEL org.opencontainers.image.source="https://github.com/acme/api" \
org.opencontainers.image.revision="a3f9c21" \
org.opencontainers.image.licenses="MIT"org.opencontainers.image.source is worth setting on every image, because it links the artefact back to the repository that produced it — the first question anyone asks about an unfamiliar image in a registry.
HEALTHCHECK — a command Docker runs periodically:
dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1))"Useful with plain Docker and Compose. Ignored entirely by Kubernetes, which has its own probes (Chapter 13.6.11). --start-period is the grace window during which failures do not count, for slow-starting applications.
STOPSIGNAL SIGQUIT — the signal sent on docker stop. The default is SIGTERM. Change it only when your program expects something else — nginx, for instance, treats SIGQUIT as graceful shutdown and SIGTERM as immediate.
SHELL ["/bin/bash", "-o", "pipefail", "-c"] — changes the shell used by shell form. Mostly useful to turn on pipefail globally, and required for Windows images where the default is cmd.
ONBUILD — records an instruction that fires when another image builds FROM yours. It makes the child's build do surprising things it cannot see in its own Dockerfile. Avoid it.
7. A complete production Dockerfile
dockerfile
# syntax=docker/dockerfile:1 # (1)
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim AS deps # (2)
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci # (3)
FROM node:${NODE_VERSION}-slim AS build # (4)
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:${NODE_VERSION}-slim AS prod-deps # (5)
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:${NODE_VERSION}-slim AS runtime # (6)
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER 1000 # (7)
EXPOSE 3000
STOPSIGNAL SIGTERM
LABEL org.opencontainers.image.source="https://github.com/acme/checkout-api"
ENTRYPOINT ["node", "dist/server.js"] # (8)(1) Selects the Dockerfile syntax version, which is what enables the --mount options. Without it, older parsers reject them.
(2) A stage that installs all dependencies, isolated so it caches on the lock file alone.
(3) A cache mount, so repeated builds reuse downloads without embedding them.
(4) The compile stage, reusing the dependencies from the previous stage rather than installing again.
(5) A separate stage for production dependencies only. This is the trick that makes the runtime stage small: development dependencies never enter the image at all, and this stage builds in parallel with the compile stage because BuildKit sees they are independent.
(6) The runtime stage. Nothing from any previous stage exists here except what is copied in.
(7) Numeric UID, so a Kubernetes runAsNonRoot policy can verify it (section 6).
(8) Exec form, so node is PID 1 and receives SIGTERM.
Four stages, three of which never ship. The runtime layer stack is: Debian slim, production node_modules, compiled dist, and a package.json. Around 95 MB.
And the corresponding graceful shutdown in the application, because the Dockerfile can only deliver the signal — the code has to act on it:
ts
// server.ts
const server = app.listen(3000);
process.on("SIGTERM", () => { // (1)
server.close(() => { // (2)
void pool.end().then(() => process.exit(0)); // (3)
});
setTimeout(() => process.exit(1), 10_000).unref(); // (4)
});(1) The signal the orchestrator sends first. Because we used exec form, this process is PID 1 and this handler is what makes SIGTERM mean anything.
(2) Stop accepting new connections, let in-flight requests finish. This is what "graceful" means concretely.
(3) Close the database pool, then exit cleanly.
(4) A backstop. If a request hangs forever, exit anyway rather than waiting for SIGKILL. unref() stops this timer from keeping the process alive on its own.
8. A Python service, for contrast
Different ecosystem, same principles, and one extra trap:
dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install --no-cache-dir poetry==1.8.3
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt # (1)
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \ # (2)
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
&& rm -rf /wheels # (3)
COPY --chown=1000:1000 src/ ./src/
USER 1000
ENTRYPOINT ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0"] # (4)(1) Build wheels in the build stage, where compilers exist. Packages with C extensions compile here and the runtime stage installs pre-built wheels, so no compiler ships.
(2) PYTHONUNBUFFERED=1 is not optional in a container. Python buffers standard output when it is not a terminal, so your logs appear in bursts or vanish entirely when the container is killed. This one line is the fix for "my Python container has no logs".
(3) Install and remove the wheels in one RUN, so they are in no finished layer.
(4) --host 0.0.0.0, not 127.0.0.1. A server bound to loopback inside a container is reachable only from inside that container, which produces "the port is published but connection refused" — one of the most common container networking confusions, covered in Chapter 13.6.6.
9. Debugging a build
bash
docker build --progress=plain . # (1)
docker build --no-cache . # (2)
docker build --target build -t dbg . # (3)
docker run --rm -it dbg sh # (4)
docker history img --no-trunc # (5)(1) Full output rather than the collapsed view — you can see which step ran and which was cached. (2) Proves whether a problem is a stale cache. (3) Build only up to a named stage, which is how you inspect an intermediate stage that is normally thrown away. (4) A shell inside that stage, to see what is actually there. (5) Which instruction added the bytes.
When a build fails at step N, build with --target up to step N-1 and open a shell. The state at the moment of failure is right there, and this is far faster than adding RUN ls lines and rebuilding.
What the interviewer will push on
"How would you reduce a 1.4 GB image?" Multi-stage so compilers, development dependencies and source never ship; a slim or distroless base; a .dockerignore; and deleting caches in the same RUN that created them, because a later rm cannot shrink an earlier layer. Then docker history to find the actual instruction rather than guessing. The separate production-dependencies stage is the detail that marks someone who has done it.
"Why does my container take exactly 30 seconds to stop?" The shell form of CMD makes sh PID 1, and sh does not forward SIGTERM to children. PID 1 also has no default signal handlers, so nothing happens until the grace period expires and SIGKILL lands, dropping in-flight requests. Fix with exec form, a real handler, and exec "$@" at the end of any entrypoint script.
"Where do you put a token needed during a build?" A BuildKit secret mount. Never ARG or ENV — build arguments are recorded in the image history and readable by anyone who can pull it, and ENV sits in the environment of every process in the container.
"Explain the cache and what invalidates it." Each instruction is a cache key; a miss invalidates everything after it. COPY keys on file content and permissions, everything else on the instruction text. Order least-changing first: manifests, install, then source. The extra point that lands: ordering also decides how many bytes each deploy pushes and pulls, because the registry skips blobs it already has.
"ENTRYPOINT or CMD?" ENTRYPOINT is the command and extra arguments append to it; CMD is defaults and gets replaced entirely. Use ENTRYPOINT when the image is a program, CMD alone when you want docker run img bash to work for debugging.
"Alpine or slim?" Slim by default, because musl differs from glibc in ways that break native modules and have historically affected DNS, and the debugging time exceeds the 70 MB saved. Distroless when the attack surface matters, accepting no shell and using ephemeral debug containers instead.
"Why USER 10001 and not USER app?" Because Kubernetes' runAsNonRoot check reads the numeric UID from the image config and cannot resolve a username, so a name-based USER fails the policy even when it is genuinely not root.
One thing to volunteer: point out that the build context is uploaded before the first instruction runs, so a missing .dockerignore both slows every build and can copy .git — with every credential ever committed and removed — into the shipped image. It is a two-line file that fixes a performance problem and a security problem at the same time.
Recall
- Only
RUN,COPYandADDadd filesystem content. Everything else writes metadata into the config. - Order least-frequently-changing first: manifests → install → source. Reversing it reinstalls every dependency on every commit and re-pushes every layer.
- Multi-stage is the biggest single win. A separate production-dependencies stage keeps development packages out entirely;
COPY --fromalso works from a published image. apt-get updateandinstallin oneRUN, with--no-install-recommendsandrm -rf /var/lib/apt/lists/*in the same instruction. Useset -o pipefailor a failedcurl | shexits 0 and ships a broken image.- BuildKit mounts:
--mount=type=cachefor package caches,--mount=type=secretis the only correct way to use a credential at build time.ARGandENVboth leak — build args are indocker history. - Exec form for
CMD/ENTRYPOINT, andexec "$@"at the end of an entrypoint script, orshstays PID 1 and swallowsSIGTERM. HandleSIGTERMin the application too. EXPOSEpublishes nothing.VOLUMEin a Dockerfile makes laterRUNwrites to that path vanish.USERtakes a numeric UID sorunAsNonRootcan verify it.COPY --chowninstead of a laterRUN chown -R, which duplicates every file..dockerignorematters twice: the context uploads before the build, andCOPY . .will otherwise ship.gitand.env.PYTHONUNBUFFERED=1for Python logs; bind to0.0.0.0, not loopback.- Debug with
--progress=plain,--no-cache, and--target <stage>plus a shell into the intermediate stage.
Self-test: Which three instructions add layers, and why does that answer most size questions? · What breaks if apt-get update is its own instruction? · Where does an ARG-passed token end up? · Why does exec "$@" matter in an entrypoint script? · Why USER 10001 rather than USER app? · What does EXPOSE actually do? · Why is --host 0.0.0.0 needed inside a container?
Next: 13.6.6 runs the image — every flag that matters on docker run, the four network modes drawn out, volumes against bind mounts, and why a published port can still refuse connections.