The idea: an image is a stack of read-only layers

A Docker image is not one big file. It is a stack of layers. Each Dockerfile instruction that changes files (FROM, COPY, RUN) adds one layer: a tar archive of only the files that step added, changed or deleted. Every layer is named by the hash of its content, its digest (sha256:…). A small JSON manifest lists the image config and the layer digests in order. That is the whole image.

Left: the Dockerfile FROM node:20-slim, COPY package.json /app/, RUN npm install, COPY src/ /app/src/, CMD node src/server.js, tagged L1 to L4 and config. Right: the stack of layers L1 base 74 MB at the bottom, L2 package.json 1 KB, L3 node_modules 38 MB, L4 src 4 KB at the top, each with a sha256 digest. CMD goes to the image config, not to a layer. The manifest lists the config and the four layer digests
Each instruction that changes files adds one layer named by its digest; CMD only changes the config.

On the canvas: the Dockerfile and its build context on the left, the image's layer stack in the middle, a registry on the right, and two containers below. Start with docker build, or run a demo.

One layer per instruction, named by its content

instructionmakes a layer?what is in it
FROM node:20-slimuses the base image's layersDebian files and Node.js (several layers in reality, one here)
COPY / ADDyesthe copied files
RUNyesevery file the command created, changed or deleted
CMD, ENV, EXPOSE, LABEL, USERnoonly the image config (JSON)

Because a layer is named by the hash of its bytes, the same layer is stored once on a host and once in a registry, however many images use it. A digest also proves the content: if one byte changes, the name changes. The page uses short made-up digests; real ones are 64 hex characters of SHA-256 over the compressed tar. docker history myapp:1.0 lists the layers with the instruction that made each one.

The build cache: hit, miss, and everything after a miss

Before running a step, BuildKit computes a cache key from the parent layer, the instruction text and, for COPY and ADD, a checksum of the copied files (their content and metadata, not their timestamps). If the cache already holds that key, the step is skipped and the old layer is reused: CACHED in the build output, 0 s (Demo 1). If not, the step runs and makes a new layer.

A new layer means a new parent for the next step, so its key is new too. After the first miss, every later step misses, even if its own instruction and files did not change. A RUN key does not look at the network either: RUN apt-get update stays cached for months unless something above it changes.

Why the order of the steps matters

So put the steps that rarely change first, and the files you edit all day last. The classic case is a Node.js app (the same holds for pip install -r requirements.txt, go mod download, mvn dependency:go-offline):

After editing src/server.js. Left, good order: FROM base, COPY package.json CACHED, RUN npm install CACHED, COPY src/ rebuilt, total 1 s. Right, bad order: FROM base, COPY . rebuilt because src changed, RUN npm install rebuilt because its parent changed, total 41 s
Copy package.json and install first: a source edit then rebuilds one 4 KB layer, not npm install.
after editing src/server.jsgood orderbad order (COPY . first)
steps rebuiltonly COPY src/COPY . and RUN npm install
build time1 s41 s
new layers to push4 KB5 KB + 38 MB

(Demo 2 and Demo 3.) Editing package.json does re-run npm install in both orders, as it should. In the good order the following COPY src/ also misses (its parent changed), but it copies the same files, so it gets the same digest as before and nothing new has to be pushed. A .dockerignore that leaves out node_modules, .git and logs keeps unrelated files from changing the checksum of COPY ..

Union mount: lowerdir, upperdir, merged

To run a container, Docker's overlay2 driver mounts an OverlayFS: the image layers as the read-only lowerdir (L4:L3:L2:L1, highest first), one new empty directory as the writable upperdir, and the merged directory, which becomes the container's /. A name in the merged view comes from the highest layer that has it. Nothing is copied when a container starts, so it starts in milliseconds and uses no extra disk.

Two containers A and B on one image. At the bottom, the shared read-only layers L1 to L4. Above them, each container has its own upperdir: A's holds config.json copied up and a whiteout for package.json, B's is empty. On top, A's merged view shows config.json from upper and no package.json; B's merged view shows every file from the image layers
Both containers share the same image layers; only the thin upper layer is per container.

The container's thin writable layer is the upperdir:

  • Copy-up on write. Writing to a file from a lower layer first copies the whole file into the upperdir, then changes the copy. The image layer never changes, so another container still sees the original. One byte changed in a 1 GB file costs a 1 GB copy, which is why databases keep their data in a volume.
  • Whiteout on delete. Deleting a file from a lower layer can't remove it from that layer, so OverlayFS creates a whiteout (a character device with number 0/0 and the same name) in the upperdir. The merged view hides the name. A deleted directory that is created again gets an opaque marker instead.
  • New files go straight into the upperdir.

(Demo 4.) docker diff lists exactly what is in a container's upperdir (C changed, A added, D deleted), and docker commit turns it into a new layer. docker rm deletes it.

Whiteouts also explain a common surprise: RUN rm -rf /var/lib/apt/lists/* in its own step does not make an image smaller, because the files are still in the layer below. Clean up in the same RUN that created the files, or use a multi-stage build and COPY --from=build only what the final image needs.

Push and pull: only the missing layers travel

A registry (Docker Hub, GHCR, ECR, Harbor) stores blobs by digest and manifests by tag, through the OCI Distribution API (Demo 5):

steprequestanswer
does the registry have this layer?HEAD /v2/myapp/blobs/sha256:…200 (skip: "Layer already exists") or 404
upload a missing layerPOST then PUT /v2/myapp/blobs/uploads/…?digest=sha256:…201 Created
publish the tagPUT /v2/myapp/manifests/1.0201 Created

A pull is the same in reverse: GET the manifest for the tag, then GET only the blobs whose digests the host does not have yet. So a rebuild that changed only the top 4 KB layer is a 4 KB push and a 4 KB pull on every server, which is the payoff of a good layer order. Kubernetes nodes pull the same way when a pod starts (see how Kubernetes talks to containerd and Docker).

See also: What a Container Really Is shows the rest of docker run (runc, namespaces, cgroups, veth) with a short OverlayFS section; this page goes deeper into how the layers are built, cached, shared and shipped. How the kernel follows a path through a mount point is shown in What open() Does.

What the page leaves out

The base image's own several layers and multi-platform image indexes (linux/amd64, linux/arm64), compression (gzip, zstd) and the difference between the compressed digest and the uncompressed diff_id, the image config's rootfs.diff_ids and chain IDs, BuildKit's parallel stages, cache mounts (RUN --mount=type=cache), remote cache (--cache-from, --cache-to), cache eviction (docker builder prune), ADD with URLs, OverlayFS workdir, redirect_dir and metacopy, other storage drivers (btrfs, zfs, containerd snapshotters), lazy pulling (eStargz, SOCI), cross-repository blob mounts, and image signing.