Layers and Build Caching
By the end of this lesson
Order instructions so rebuilds stay fast.
Each instruction in a Dockerfile produces a layer, and Docker remembers the layer it produced last time. On the next build it walks the instructions in order and reuses a layer whenever the instruction and its inputs are unchanged. That is the whole build cache.
The part that decides how fast your builds are is what happens on a miss. When one layer is invalidated, every layer after it is rebuilt too — not because those instructions changed, but because they now run against a different starting point. A cache miss early in a Dockerfile is expensive; the same miss near the end costs almost nothing.
Two inputs decide whether a layer can be reused. For COPY, the contents of the files being copied. For RUN, the command text itself. Docker does not inspect what a command would do, only whether the instruction and the files feeding it are the same.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /src
# Project file and source arrive together
COPY . ./
# So both of these run again on every build
RUN dotnet restore
RUN dotnet publish -c Release -o /app --no-restore
ENTRYPOINT ["dotnet", "/app/EmployeesApi.dll"]- COPY . ./ takes the contents of every file in the context as its cache input. Edit one line in one controller and this layer is invalid.
- Because that layer is invalid, dotnet restore reruns. It downloads every package again even though no dependency changed.
- Then the publish reruns, which it genuinely had to. Only one of the two rebuilds was necessary.
- Nothing in the build output explains this. You see the steps running and assume that is how long a build takes.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /src
# First, only the file that lists dependencies
COPY EmployeesApi.csproj ./
RUN dotnet restore
# Then the source, which changes constantly
COPY . ./
RUN dotnet publish -c Release -o /app --no-restore
ENTRYPOINT ["dotnet", "/app/EmployeesApi.dll"]- The project file lists the NuGet packages the API depends on. It changes when you add or upgrade a package — a few times a month, perhaps.
- Because the restore layer's only input is that project file, editing source code leaves it cached. The packages are already in the image from the previous build.
- COPY . ./ still misses the cache on every source change, which is correct — the source did change. The difference is that only the publish step runs after it.
- --no-restore tells publish not to repeat the restore. Without it, publish quietly redoes the work you just arranged to cache.
- The same pattern applies in any ecosystem. For the Next.js frontend it is package.json and package-lock.json first, then npm ci, then the source.
The two files build the same application. They behave completely differently on the second build:
| Source copied first | Dependencies restored first | |
|---|---|---|
| First COPY brings in | Everything, source included | Only the project file |
| After a one-line source edit | Restore and publish both rerun | Only publish reruns |
| After adding a package | Restore and publish rerun | Restore and publish rerun — correctly, this time |
| Packages downloaded per build | All of them, every time | None, unless dependencies changed |
| Cost of the arrangement | None to write, paid on every build | Two extra lines, paid once |
On the employees API, with about thirty NuGet packages, a one-line source change rebuilt in roughly 95 seconds with source copied first, and roughly 10 seconds after reordering. Treat those numbers as indicative — they move with your machine, your package count and your network — but the shape holds everywhere: fetching dependencies dominates a rebuild, and the reordering removes it from the case that happens fifty times a day.
There is no downside to weigh here, which is unusual. The reordered file is two lines longer and produces the same image.
Summary
- Every instruction is a layer, and Docker reuses layers whose instruction and inputs are unchanged
- One invalidated layer forces every later layer to rebuild
- COPY is keyed on file contents, RUN on the command text
- Copy dependency manifests and install dependencies before copying source, because source changes constantly and dependencies rarely do
- The reordering costs two lines and saves most of the time in a typical rebuild
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Measure the difference
Take a Dockerfile that copies everything before restoring dependencies. Build it, change one line of source, and build again. Note the time.
Reorder it so the dependency manifest is copied and restored first. Repeat the same edit-and-rebuild. Compare, and read the step output to see which steps report CACHED.
Show solution
The second arrangement skips the dependency restore entirely, and the build output says CACHED against that step. The saving is the whole restore.
Doing this by hand matters more than reading the rule, because the fast version feels different to work with. A ten-second rebuild gets used in a tight loop; a ninety-second one changes how you work, and not for the better.
docker build -t employees-api:slow -f Dockerfile.slow .
# edit one line of source
docker build -t employees-api:slow -f Dockerfile.slow .
docker build -t employees-api:fast -f Dockerfile.fast .
# make the same edit
docker build -t employees-api:fast -f Dockerfile.fast .Challenge
Apply it to the frontend
The Next.js frontend Dockerfile copies the whole project and then runs npm ci. Restructure it so editing a component does not reinstall dependencies.
Which files have to be copied first, and what goes wrong if you copy only package.json?
Show solution
Copy package.json and package-lock.json, run npm ci, then copy the rest. The install layer then depends only on the two dependency files.
Copying only package.json breaks the install, because npm ci requires the lockfile — it installs the exact versions recorded there rather than resolving ranges. Without it the command fails outright, which is better than the alternative would have been: resolving to different versions than your team is using.
That is the general lesson about lockfiles. They are the input that makes an install repeatable, so they belong in the cached layer alongside the manifest.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . ./
RUN npm run buildSaved in this browser only.