Layers and the build cache

Why order matters, what busts the cache, what image sizes mean and where layers live.

Beginner12 min · lesson 9 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (8 files, 8 KB): layers.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/layers.tar.gz && tar -xzf layers.tar.gz, which creates ~/lab/layers/. SHA-256: c7e35e827947faaec6a141b6d41e350ec644c5ac6c47f35efc8dcf8e3b9e2b89

The order of the instructions in a Dockerfile decides how much of each rebuild BuildKit can skip. Get it wrong and a pipeline reinstalls every dependency on every commit, even for a one-line README fix; get it right and a code change rebuilds one small layer. This lesson measures both, using two ideas: an image is a stack of layers, and BuildKit reuses a cached layer only while everything that went into it is unchanged. It also shows what the image sizes mean and why deleting a file in a later step does not remove it.

Use the main lab VM and unpack the lesson files into ~/lab/layers. They contain the small Express service from "Writing a Dockerfile" (renamed payments-api), its committed package-lock.json, a .dockerignore, the Dockerfile in the right order, Dockerfile.bad in the wrong order, and a third Dockerfile for the last section.

Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

An image is a stack of layers

Each Dockerfile instruction that changes files (COPY, ADD, RUN, and WORKDIR when it creates the directory) produces a layer: a set of file additions, changes and deletions relative to the layers below it. Instructions such as ENV, USER, EXPOSE and CMD only change the image configuration and add no files. FROM adds no layer of its own; it brings in the base image's layers. Layers are identified by the digest of their content, so two images built FROM node:24-alpine store the base layers once and share them. When a container starts, Docker adds a thin writable layer on top, private to that container, as described in "Images, containers and the core commands".

An image is a stack of layers
container (one per container)
writable layer
files the running container creates or changes; removed with the container
your instructions (read-only)
COPY . .
server.js
RUN npm ci --omit=dev
node_modules, about 9 MB
COPY package.json package-lock.json
the manifests
WORKDIR /app
an empty directory
base image node:24-alpine (read-only, shared)
Node.js 24 runtime
about 161 MB unpacked
Alpine 3.24 root filesystem
about 9 MB
Read it bottom to top. Many images and containers can share the lower layers; only the top layer belongs to one container.

Build once and list the layers

The first build uses --no-cache so every step runs, as it would on a fresh machine. As in "Writing a Dockerfile", the plain progress format is shown and npm's update notice is cut.

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker build --no-cache -t lab-layers:1 .
#0 building with "default" instance using docker driver ... #5 [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 #5 resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s done #5 DONE 0.0s #6 [2/5] WORKDIR /app #6 CACHED #7 [3/5] COPY package.json package-lock.json ./ #7 DONE 0.1s #8 [4/5] RUN npm ci --omit=dev #8 1.563 #8 1.563 added 68 packages, and audited 69 packages in 1s #8 1.563 #8 1.563 28 packages are looking for funding #8 1.563 run `npm fund` for details #8 1.564 #8 1.564 found 0 vulnerabilities ... #8 DONE 1.7s #9 [5/5] COPY . . #9 DONE 0.1s #10 exporting to image #10 exporting layers #10 exporting layers 0.2s done #10 exporting manifest sha256:ca044ffdb3a831591d7863128fbdadbad72e994cd6719cc651320b858ab5ae5d done #10 exporting config sha256:25a176a9bdd2dd6b370e7e7efb18debad6301f08ae71d5bdfef89eefd71c85b6 done #10 exporting attestation manifest sha256:98f0f4338389b5c98f2ab36e076b55b198aa315f8346c8bf57615d53b4c2c246 0.0s done #10 exporting manifest list sha256:5fb79aa73da7c3d0fea3f18a13a6e001c8653aca334ec2734eb3b446e613c8d4 done #10 naming to docker.io/library/lab-layers:1 done #10 unpacking to docker.io/library/lab-layers:1 #10 unpacking to docker.io/library/lab-layers:1 0.2s done #10 DONE 0.4s

docker history lists the layers of an image, newest first, with the instruction that created each and the size it added.

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker history lab-layers:1
IMAGE CREATED CREATED BY SIZE COMMENT 5fb79aa73da7 1 second ago CMD ["node" "server.js"] 0B buildkit.dockerfile.v0 <missing> 1 second ago EXPOSE [3000/tcp] 0B buildkit.dockerfile.v0 <missing> 1 second ago USER node 0B buildkit.dockerfile.v0 <missing> 1 second ago COPY . . # buildkit 16.4kB buildkit.dockerfile.v0 <missing> 1 second ago RUN /bin/sh -c npm ci --omit=dev # buildkit 9.42MB buildkit.dockerfile.v0 <missing> 2 seconds ago COPY package.json package-lock.json ./ # bui… 45.1kB buildkit.dockerfile.v0 <missing> 10 minutes ago WORKDIR /app 8.19kB buildkit.dockerfile.v0 <missing> 2 weeks ago CMD ["node"] 0B buildkit.dockerfile.v0 <missing> 2 weeks ago ENTRYPOINT ["docker-entrypoint.sh"] 0B buildkit.dockerfile.v0 <missing> 2 weeks ago COPY docker-entrypoint.sh /usr/local/bin/ # … 20.5kB buildkit.dockerfile.v0 <missing> 2 weeks ago RUN /bin/sh -c apk add --no-cache --virtual … 5.48MB buildkit.dockerfile.v0 <missing> 2 weeks ago ENV YARN_VERSION=1.22.22 0B buildkit.dockerfile.v0 <missing> 2 weeks ago RUN /bin/sh -c addgroup -g 1000 node && … 161MB buildkit.dockerfile.v0 <missing> 2 weeks ago ENV NODE_VERSION=24.21.0 0B buildkit.dockerfile.v0 <missing> 2 weeks ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0 <missing> 2 weeks ago ADD alpine-minirootfs-3.24.2-aarch64.tar.gz … 9.31MB buildkit.dockerfile.v0

The top seven rows are this Dockerfile; everything from CMD ["node"] down came from the base image, which was built two weeks before this run. Your sizes and ages will differ. The configuration-only instructions (CMD, EXPOSE, USER, ENV) show 0B. RUN npm ci added 9.42MB, the largest layer of your own. The bottom row shows the Alpine root filesystem for aarch64, because the lab VM is an arm64 machine; on an amd64 host the same tag resolves to the amd64 variant and that row names an x86_64 archive. <missing> in the IMAGE column only means those intermediate layers have no image ID of their own; it is not an error. The COMMENT column shows that BuildKit built every layer, including the base image's.

How the cache decides

On each build, BuildKit walks the instructions in order and asks whether it already has a result for this exact step on top of this exact parent. For RUN, the key is the command text; BuildKit does not look at what the command would download. For COPY and ADD, the key includes a checksum of the files being copied. The first instruction whose key changed is executed again, and so is every instruction after it, because each one builds on the result of the one before.

Append a comment to server.js (with the current time, so the change is new every time you try this) and build again.

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ echo "// edited $(date +%T)" >> server.js tail -n 1 server.js
// edited 00:00:09
$ docker build -t lab-layers:2 .
... #6 [3/5] COPY package.json package-lock.json ./ #6 CACHED #7 [2/5] WORKDIR /app #7 CACHED #8 [4/5] RUN npm ci --omit=dev #8 CACHED #9 [5/5] COPY . . #9 DONE 0.0s ...

WORKDIR, the manifest copy and npm ci come back CACHED because none of their inputs changed. Only COPY . . ran, since server.js is part of what it copies. The plain output lists steps in the order BuildKit finished resolving them, which is not always file order; the [n/5] numbers give the Dockerfile order.

The wrong order

Dockerfile.bad copies everything first and installs afterwards.

Dockerfile.bad
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Build it once, change server.js again, and rebuild.

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker build -q -f Dockerfile.bad -t lab-layers:bad .
sha256:b823834a18dfc2d78bd9e111433ad84ef4a31c6262b29173985da89f56abb8e0
$ echo "// edited $(date +%T)" >> server.js
$ docker build -f Dockerfile.bad -t lab-layers:bad .
... #6 [2/4] WORKDIR /app #6 CACHED #7 [3/4] COPY . . #7 DONE 0.0s #8 [4/4] RUN npm ci --omit=dev #8 0.906 #8 0.906 added 68 packages, and audited 69 packages in 739ms #8 0.906 #8 0.907 28 packages are looking for funding #8 0.907 run `npm fund` for details #8 0.907 #8 0.907 found 0 vulnerabilities ... #8 DONE 1.0s ...

This time COPY . . sits below the install, so the edited file changes its key, and npm ci runs again for a change that has nothing to do with dependencies. Here that costs about a second, because the app has a single dependency. A real service with a few hundred packages pays minutes per build, on every commit. The rule follows directly: copy what changes rarely (dependency manifests, then the install) before what changes often (the code). The same pattern exists for every ecosystem: requirements.txt before pip install, go.mod and go.sum before go mod download, pom.xml before the Maven dependency step.

A cached step is reused however old its result is. A RUN apt-get update && apt-get install -y curl step keeps its cached result until something below it changes, however old the package lists get. Pin versions where repeatability matters, and rebuild with --no-cache (or --pull to also check for a newer base image) when you deliberately want fresh results, for example in a scheduled rebuild that picks up security fixes.

What the sizes mean

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker image ls lab-layers
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-layers:1 5fb79aa73da7 249MB 64MB lab-layers:2 143989f8aef2 249MB 64MB lab-layers:bad 66f33789590d 249MB 64MB
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 9 0 923.7MB 674.8MB (73%) Containers 0 0 0B 0B Local Volumes 2 0 8.257MB 8.257MB (100%) Build Cache 112 0 511.2MB 96.59MB

All three images report 249MB of DISK USAGE, but they do not use 747MB between them. DISK USAGE counts every layer an image needs, including the shared base, and the images here differ only in their top layers. CONTENT SIZE (64MB) is the compressed size of the layers, roughly what a pull or push transfers. docker system df totals what is actually stored, counting shared layers once; the image count and the totals include everything on the VM, so yours will differ.

The Build Cache row is BuildKit's store of step results, including results no current image uses. It is kept until BuildKit's garbage collection trims it or you remove it with docker builder prune. That command clears cache for every project on the host, so on a shared machine leave it to whoever looks after the host.

Where the layers live

On a fresh Docker 29 install, image content is stored by containerd, not by Docker's older graph drivers: the storage driver reports overlayfs with driver-type io.containerd.snapshotter.v1, layer content sits under /var/lib/containerd, and /var/lib/docker has no overlay2 directory. Hosts upgraded from older versions keep the classic overlay2 driver until they migrate. "Where Docker keeps data" in Docker in depth explains both layouts and how to tell which one a host uses.

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker info -f '{{.Driver}} {{json .DriverStatus}}'
overlayfs [["driver-type","io.containerd.snapshotter.v1"]]
$ sudo ls /var/lib/containerd
io.containerd.content.v1.content io.containerd.snapshotter.v1.btrfs io.containerd.grpc.v1.introspection io.containerd.snapshotter.v1.erofs io.containerd.metadata.v1.bolt io.containerd.snapshotter.v1.native io.containerd.runtime.v2.task io.containerd.snapshotter.v1.overlayfs io.containerd.sandbox.controller.v1.shim tmpmounts io.containerd.snapshotter.v1.blockfile
$ sudo ls /var/lib/docker
buildkit engine-id network rootfs swarm volumes containers image plugins runtimes tmp

A deleted file is still in the image

Layers only add. A later instruction that deletes a file records the deletion in its own layer and hides the file from the final filesystem, but the earlier layer still contains it and anyone with the image can extract it. The third Dockerfile copies a stand-in key file, uses it, and deletes it.

Dockerfile.secret
FROM alpine:3.22
COPY fake-key.txt /tmp/fake-key.txt
RUN wc -c /tmp/fake-key.txt
RUN rm /tmp/fake-key.txt
ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker build -q -f Dockerfile.secret -t lab-layers:secret .
sha256:9e030237094e29f82cfe4051cf777838928092aef2a612914f17debb318695aa
$ docker history --format 'table {{.CreatedBy}}\t{{.Size}}' lab-layers:secret | head -n 4
CREATED BY SIZE RUN /bin/sh -c rm /tmp/fake-key.txt # buildk… 8.19kB RUN /bin/sh -c wc -c /tmp/fake-key.txt # bui… 4.1kB COPY fake-key.txt /tmp/fake-key.txt # buildk… 12.3kB

The COPY layer still holds the 3 KB file (12.3kB on disk, rounded up to filesystem blocks), and the rm layer did not shrink anything; it added a few kilobytes of its own. Keep secrets out of build instructions entirely. "Build-time secrets and how images leak them" in Advanced container security shows how to extract such a file and the build features that avoid the problem.

Clean up

ubuntu@secopslog-docker:~/lab/layers · Docker 29.8.2
$ docker rmi lab-layers:1 lab-layers:2 lab-layers:bad lab-layers:secret
Untagged: lab-layers:1 Deleted: sha256:5fb79aa73da7c3d0fea3f18a13a6e001c8653aca334ec2734eb3b446e613c8d4 Untagged: lab-layers:2 Deleted: sha256:143989f8aef22796810fbcb264c2fe3c234947a0ed2a86d0fc88c3b5bc1ebc79 Untagged: lab-layers:bad Deleted: sha256:66f33789590d3f69a50ec3485da6f40b49d4faaed3990c59839761845b20fdb5 Untagged: lab-layers:secret Deleted: sha256:9e030237094e29f82cfe4051cf777838928092aef2a612914f17debb318695aa
Quick check
01You edit one line of server.js and rebuild. BuildKit prints CACHED for WORKDIR, COPY package.json package-lock.json and RUN npm ci, then runs COPY . .. Why was npm ci skipped?
Correct — The cache key of the install depends only on the steps below it, and none of them involve server.js.
Incorrect — A RUN step reruns whenever any step before it changed. In the wrong-order Dockerfile it did.
Incorrect — RUN executes at build time in a fresh build step; there is no container writable layer involved, and npm ci did not run at all.
Incorrect — The base image knows nothing about your dependencies. The saving comes from the order of your own instructions.
02docker image ls shows three of your images at 249MB DISK USAGE each. How much disk do they take together?
Incorrect — Layers are stored once by content digest and shared. The base layers here are stored once for all three images.
Incorrect — CONTENT SIZE is the compressed transfer size. Images are also unpacked on disk so containers can start from them.
Correct — DISK USAGE counts shared layers in every image, and system df counts them once.
Incorrect — Pulled and built images are stored locally; the registry is only where they are pushed to and pulled from.
03A Dockerfile copies deploy-key.pem in one step, uses it in a RUN step, and deletes it in a third RUN step. A teammate says the key is not in the image because the final filesystem no longer shows it. Are they right?
Incorrect — BuildKit keeps each layer. The history in the lab shows the COPY layer with its full size after the delete.
Correct — A delete only adds a layer that hides the file; the bytes stay in the earlier layer.
Incorrect — Same build or not, the layer that added the file is kept and can be extracted.
Incorrect — Registries store layers as they are. Anyone with access to the image, public or private, can read the layer.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from layers and the build cache, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related