Docker layer caching can make container builds much faster, but it does not guarantee that every build will be quick. A Dockerfile can have good cache usage and still spend most of its build time downloading packages, compiling source code, restoring dependencies, or transferring a large build context.
This becomes noticeable in CI pipelines where an application is rebuilt many times during development and deployment. A build that takes a few seconds locally can take several minutes in CI even though Docker reports that many layers are cached.
The important thing is to stop looking at Docker caching as a single switch. A Docker build is a sequence of operations, and each operation can become the bottleneck.
How Docker Layer Caching Actually Works
A Dockerfile is processed as a series of instructions. Each instruction can produce a filesystem layer or contribute to the build result.
For example:
FROM mcr.microsoft.com/dotnet/sdk:11.0
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app
The build has several distinct stages:
Base image
↓
Working directory
↓
Source files copied
↓
Dependencies restored
↓
Application compiled and published
Docker can reuse previous build results when the inputs for an instruction have not changed.
The problem is that a change to an earlier instruction can invalidate the cache for everything that follows it.
For example, if this instruction changes:
COPY . .
then the following restore and publish steps may need to run again.
This is why Dockerfile ordering has a major effect on build performance.
A Cached Build Can Still Be Slow
Suppose a build output looks something like:
[1/6] FROM base image CACHED
[2/6] WORKDIR /src CACHED
[3/6] COPY . . CACHED
[4/6] dotnet restore 8s
[5/6] dotnet build 25s
[6/6] dotnet publish 40s
Docker is clearly using cache for some steps.
Yet the build still takes more than a minute.
The reason is simple: caching only helps with steps whose cache entries remain valid.
If the expensive steps are being invalidated frequently, the presence of caching does not solve the underlying problem.
This is why the first troubleshooting step should be identifying which build step is actually consuming time.
Start by Measuring the Build
Do not change the Dockerfile based only on assumptions.
Build with detailed output:
docker build --progress=plain -t myapp .
The plain progress output makes it easier to see which operations are cached and which ones are actually running.
Look for steps such as:
RUN dotnet restore
RUN dotnet build
RUN npm install
RUN npm run build
RUN apt-get update
RUN pip install ...
A slow build is usually caused by one or two expensive operations rather than Docker itself.
Also compare the build time for a warm cache and a cold cache.
For example:
Cold build: 4m 20s
Warm build: 2m 10s
If both are slow, cache invalidation may not be the only problem.
The Docker Build Context Can Be Larger Than Expected
Before Docker executes the Dockerfile, it needs access to the build context.
A command such as:
docker build -t myapp .
uses the current directory as the build context.
If that directory contains unnecessary files, Docker may need to send a large amount of data to the build engine.
A project directory might accidentally contain:
.git/
bin/
obj/
node_modules/
test-results/
coverage/
logs/
temporary files
These files may have nothing to do with the container image.
A .dockerignore file can prevent unnecessary files from being included.
For example:
.git
bin
obj
node_modules
TestResults
coverage
*.log
The exact entries should match the project.
This is particularly important in CI because the build context may be transferred from the CI workspace to a remote Docker builder.
Why .dockerignore Matters More in CI
On a local machine, the build context may be transferred quickly because the Docker daemon is nearby.
In CI, the Docker builder may run on another machine or inside another service.
The process can look like:
CI workspace
↓
Build context transfer
↓
Docker builder
↓
Dockerfile execution
If the context contains hundreds of megabytes of unnecessary files, the build can spend significant time transferring data before expensive Dockerfile steps even begin.
This is why a small Docker context is a performance optimization, not just a cleanup preference.
Copy Dependency Files Before Source Code
One of the most useful Dockerfile optimizations is separating dependency files from application source.
Consider a .NET application.
A less efficient Dockerfile might do this:
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app
A small source-code change can invalidate the COPY instruction and cause dependency restoration to run again.
A better structure is:
WORKDIR /src
COPY *.sln .
COPY src/MyApp/*.csproj src/MyApp/
RUN dotnet restore
COPY src/MyApp/ src/MyApp/
RUN dotnet publish src/MyApp/MyApp.csproj \
-c Release \
--no-restore \
-o /app
Now the dependency restore layer can remain cached when only source files change.
The exact paths depend on the project structure, but the principle is the same:
Dependency files
↓
Restore
↓
Source files
↓
Build
instead of:
All source files
↓
Restore
↓
Build
This distinction can save substantial time when dependencies change less frequently than source code.
Do Not Copy the Entire Repository Too Early
A common Dockerfile pattern is:
COPY . .
near the beginning of the build.
It is convenient, but it creates a large cache dependency.
Any change to files included in that COPY instruction can invalidate the next layers.
For example, changing:
README.md
or:
documentation/
could cause later build steps to run again if those files are part of the copied context.
Copy only what each build stage actually needs when practical.
This gives Docker a better chance of reusing expensive layers.
Package Restore Can Be the Real Bottleneck
Dependency restoration is often one of the slowest parts of a build.
For .NET:
dotnet restore
may download packages from configured package feeds.
For Node.js:
npm install
or:
npm ci
may download a large dependency tree.
For Python:
pip install -r requirements.txt
can have the same problem.
If the dependency layer is invalidated on every build, the application is repeatedly paying the network and package-processing cost.
This is one reason dependency caching is often more valuable than simply adding more Docker layers.
BuildKit Cache Mounts Can Help Dependency Restores
When using Docker BuildKit, cache mounts can allow package-manager caches to persist between builds.
For example, a .NET build can use a NuGet cache:
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
The exact cache location depends on the package manager and build environment.
The important distinction is that this is not the same as Docker layer caching.
Layer caching can reuse the complete result of a Docker instruction.
A cache mount can preserve dependency data even when the instruction itself needs to execute again.
That can be useful when source changes invalidate the restore layer.
Multi-Stage Builds Help Image Size and Build Separation
Multi-stage Dockerfiles separate the build environment from the runtime image.
For example:
FROM mcr.microsoft.com/dotnet/sdk:11.0 AS build
WORKDIR /src
COPY MyApp.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:11.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
This does more than reduce the final image size.
It also creates a clear boundary between compilation and runtime.
The SDK image is used only for building. The runtime image contains the application output.
However, multi-stage builds by themselves do not make compilation faster.
If the build stage is invalidated on every source change, the expensive compilation still happens.
Parallel Builds Can Change the Bottleneck
A CI system may build several images at the same time.
For example:
Service A → 2 CPU
Service B → 2 CPU
Service C → 2 CPU
Service D → 2 CPU
If the CI runner has only four CPU cores, these builds compete for resources.
The individual Dockerfiles may be perfectly optimized, but the overall build still becomes slow.
Check:
CPU available to the builder
Memory available to the builder
Disk performance
Network throughput
Number of concurrent builds
Remote builder capacity
A slow build is not always a Dockerfile problem.
Build Cache in CI Is Different From Local Cache
A developer may have a fast local build because their machine already contains:
Docker layers
Package caches
Base images
Build outputs
Dependency metadata
A fresh CI runner may have none of these.
This creates a common comparison:
Developer machine
→ warm cache
→ 20 seconds
CI runner
→ cold cache
→ 4 minutes
The Dockerfile may be identical.
The build environment is not.
If CI starts from a clean machine for every build, consider how build cache is persisted between runs.
The exact mechanism depends on the CI platform and Docker builder configuration, but the goal is the same: make reusable build data available to future builds.
Large Build Context vs Large Docker Image
These are two different problems.
A large build context affects the process of sending files to the builder.
A large image affects image creation, storage, transfer, and deployment.
For example:
Build context:
1.2 GB
Final image:
180 MB
The final image can be reasonably sized while the build itself is slow because the context is unnecessarily large.
Conversely:
Build context:
20 MB
Final image:
1.5 GB
The build context is efficient, but the resulting image may be expensive to push and deploy.
Measure both separately.
Check Base Image Pulls
Base images can also become a bottleneck.
A Dockerfile may start with:
FROM mcr.microsoft.com/dotnet/aspnet:11.0
If the image is already available locally, Docker can reuse it.
A fresh CI worker may need to download it.
Large base images therefore increase cold-build time.
This does not mean you should always choose the smallest possible image without considering compatibility and maintenance. The correct approach is to choose an appropriate runtime image and understand whether it is being repeatedly pulled.
Common Mistakes
Assuming CACHED Means the Whole Build Is Fast
Docker may cache several steps while the expensive compilation or dependency installation still runs.
Always identify the slowest actual step.
Using COPY . . Too Early
Copying the entire repository before dependency restoration can invalidate expensive layers unnecessarily.
Separate stable dependency inputs from frequently changing source files.
Ignoring the Build Context
A large context can add significant transfer time, especially in CI.
Use .dockerignore and inspect what is actually being sent to the builder.
Relying Only on Local Build Performance
A developer machine may have warm caches and faster storage.
Production CI conditions can be very different.
Adding Cache Mounts Without Measuring
Cache mounts can help package restores, but they are not a replacement for correct Dockerfile layer ordering.
First understand the bottleneck.
A Practical Docker Build Troubleshooting Checklist
When a Docker build is slow, check the problem in this order:
Run the build with detailed progress.
docker build --progress=plain -t myapp .
Identify the first consistently slow step.
Check whether that step is being invalidated by an earlier COPY or RUN instruction.
Inspect the build context and add a suitable .dockerignore file.
Separate dependency files from source files.
Check whether dependency restoration is downloading packages repeatedly.
Consider BuildKit cache mounts for package-manager caches.
Check CPU, memory, disk, and network resources available to the builder.
Compare local warm-cache builds with CI builds.
Check whether CI is reusing build cache between runs.
This sequence helps distinguish Dockerfile problems from CI infrastructure problems.
Best Practices
Keep the build context as small as practical.
Copy stable dependency files before frequently changing source code.
Use multi-stage builds to separate build and runtime environments.
Use .dockerignore to exclude repository files that are not needed during the build.
Measure build steps instead of assuming caching is the problem.
Use BuildKit cache mounts when dependency downloads are expensive and frequently repeated.
Make sure CI can reuse appropriate build cache when repeated builds are expected.
Avoid unnecessary work inside Dockerfile RUN instructions.
Keep unrelated build operations out of the same cache-sensitive layer when separating them improves reuse.
Review build performance after adding new dependencies, generated files, tests, or build tools.
Summary
Docker layer caching helps, but it does not make every Docker build fast.
When builds remain slow, first find the exact step consuming the time. Then check cache invalidation, build context size, dependency restoration, CI cache availability, and builder resources.
The biggest improvement usually comes from making expensive steps reusable instead of simply adding more cache.
Join the conversation! Your thoughts help the community grow.