What you'll learn
- Quick answer
- Every instruction produces a layer
- The build cache, and why instruction order decides everything
- COPY vs ADD: use COPY unless you have a specific reason not to
- Multi-stage builds: throw away everything except the output
- Layer discipline: chaining RUN commands and cleaning up in the same layer
- FAQ
Quick Answer
A Docker image is a stack of read-only layers, one per Dockerfile instruction, each identified by a content hash. Docker reuses a cached layer whenever its instruction and inputs haven't changed, but the moment one layer changes, every layer after it in the file rebuilds from scratch, which is why instruction order in your Dockerfile directly controls how fast your builds are. Multi-stage builds then let you throw away everything except the final artifact, shrinking the shipped image.
Every instruction produces a layer
Each line in a Dockerfile that changes the filesystem, RUN, COPY, ADD, and a few others, produces one new, read-only layer stacked on top of the one before it. FROM, ENV, CMD and similar instructions mostly just add metadata rather than a filesystem layer, but the filesystem-changing ones are what you should be thinking about when you care about build speed and image size.
Layers are stored using a union filesystem, where the final container sees all the layers merged into one view, but on disk they stay separate, immutable, and content-addressed: Docker computes a hash of each layer's inputs (the instruction plus whatever files it touches) and uses that hash as the layer's identity. This is the single fact that everything else in this post follows from: if Docker has already built a layer with that exact hash before, for this image or a completely different one, it reuses the existing layer instead of redoing the work.
A typical Dockerfile for a Node.js app looks like this, and each line below produces its own layer once built:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
The build cache, and why instruction order decides everything
When you run docker build again after a small change, Docker walks the Dockerfile top to bottom, and for each instruction checks: does a cached layer already exist for this exact instruction, given the exact same preceding layers and the exact same inputs? If yes, it reuses the cached layer without executing anything. The moment it hits an instruction where the answer is no, that's called a cache miss, and here's the part that trips people up: every instruction after that point is also treated as a miss and rebuilt, even if nothing about those later instructions actually changed. The cache invalidation cascades forward and never "heals" itself mid-file.
For COPY and ADD, Docker decides cache validity by computing a checksum of the actual file contents being copied in, not by looking at timestamps. So if you edit one character in package.json, the layer for that COPY package.json ./ instruction gets a different checksum, misses cache, and every instruction after it reruns.
This is exactly why the Dockerfile in the previous section copies package.json and package-lock.json separately, before running npm ci, and only copies the rest of the application source (COPY . .) afterward. Your source code changes on every commit; your dependency list changes rarely. If you instead wrote COPY . . first and then RUN npm ci, every single source code edit, even a one-line CSS tweak, would invalidate the layer above it and force a full npm ci reinstall on every build. Reorder it the way shown above, and an ordinary source-only change skips straight past the (often slowest) dependency-install layer and only rebuilds the last two steps.
The practical rule: order your Dockerfile from least frequently changing to most frequently changing. Base image and OS packages first, then language runtime and dependency manifests, then dependency installation, and only then your actual application source. Anything volatile (build timestamps, generated version files, .git metadata) should either come last or, better, be excluded entirely with a .dockerignore file, since Docker includes the full build context, everything in the directory you pass to docker build, when computing some of these checksums, and an untracked stray file can silently invalidate a cache you were relying on.
COPY vs ADD: use COPY unless you have a specific reason not to
COPY and ADD both copy files from the build context into the image, and for that basic case they behave identically. ADD does two extra things COPY deliberately doesn't: it can fetch a file directly from a remote URL, and it automatically extracts recognized local compressed archives (like a .tar.gz) into the destination directory.
Those extra features sound convenient and are exactly why ADD is usually the wrong choice. Auto-extracting an archive means the layer's actual contents (a whole unpacked directory tree) aren't obvious from reading the Dockerfile line, which makes images harder to reason about and audit. Fetching from a URL is worse for caching specifically: Docker can't fingerprint a remote server's file the way it fingerprints a local file, so the caching behavior around a URL-based ADD is unreliable, and there's no built-in cleanup of the downloaded artifact afterward, it just sits in that layer permanently even if you delete it in a later layer (deleting a file in a later layer hides it from the final filesystem view, it doesn't remove its bytes from the image).
The standing guidance from Docker's own best practices is blunt about this: default to COPY for bringing local files into an image, and reach for ADD only for its two specific extra behaviors, and even then, prefer running an explicit RUN curl and RUN tar instead, since that keeps the operation visible, cacheable in the normal way, and lets you clean up in the same layer.
Multi-stage builds: throw away everything except the output
A single-stage Dockerfile for a compiled or bundled app tends to accumulate weight it doesn't need at runtime: a full compiler toolchain, the entire node_modules tree including dev dependencies, test files, source maps, build caches. None of that is needed to actually run the finished application, only to build it.
Multi-stage builds solve this by letting a single Dockerfile define more than one FROM block, each one a separate, named build stage, and then selectively copying only the specific output files you need from an earlier stage into the final one. Everything else from the earlier stages, including that entire compiler toolchain, never makes it into the image you actually ship.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/htmlThe first stage, named builder via AS builder, has the full Node.js toolchain and every dependency, because it needs them to run the build. The second stage starts completely fresh from a minimal nginx:alpine base and uses COPY --from=builder to pull in only the compiled /app/dist output directory. The final image has no Node.js runtime, no npm, no node_modules, no source files, just nginx and the static files it needs to serve. This is routinely the difference between a shipped image in the hundreds of megabytes to low gigabytes, versus one in the tens of megabytes for the same application, because everything the build needed but the running app doesn't is simply never copied into the final layer stack.
The same pattern applies to compiled languages even more dramatically: a Go or Rust build stage can carry a multi-hundred-megabyte toolchain, while the final stage can be built FROM scratch or a tiny distroless base image containing nothing but the single compiled binary.
Layer discipline: chaining RUN commands and cleaning up in the same layer
Because a layer is immutable once built, deleting a file in a later RUN instruction doesn't shrink the image, the bytes are still sitting in the earlier layer, just hidden from the merged view. This trips people up constantly with package managers: if RUN apt-get update && apt-get install -y build-essential is one instruction, and RUN rm -rf /var/lib/apt/lists/* is a separate later instruction, the apt cache still exists in the image, permanently, in that earlier layer.
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*Chaining the install and the cleanup into a single RUN with &&, as shown above, means the cleanup happens before that layer is finalized, so the cache never gets committed to a layer in the first place. This is a small habit that compounds: a Dockerfile with the toolchain install and cleanup fused into one instruction can end up meaningfully smaller than the same steps split across several RUN lines, purely because of how layer immutability works.
One trade-off worth knowing: fewer, larger RUN instructions shrink the image but also mean more gets invalidated together on a cache miss, since the whole fused instruction reruns if any part of it needs to. In practice, group things that logically change together (a package list you rarely touch) into one instruction, and keep things that change independently (your application source versus your OS packages) in separate instructions, which is really the same instruction-ordering principle from earlier applied at a finer grain.
