Cut Your CI/CD Build Times by 60%: Old Cache Busting vs. Intelligent Layering
Cut CI/CD build times by 60%. This post contrasts inefficient full-cache-busting with intelligent Dockerfile layering to accelerate feedback loops and reduce
Your CI pipeline is slow because your Dockerfiles are fighting the build cache, not using it. Every time a developer pushes a one-line code change, you’re likely reinstalling every single dependency from scratch. This isn’t a CI runner problem; it’s a layer-caching strategy problem you can fix in 10 minutes.
TL;DR: Naive
COPYcommands in your Dockerfile are the number one cause of slow CI builds. By reordering your instructions to separate dependency installation from source code copying, you can leverage Docker’s layer cache effectively. This post provides a before-and-after refactor that cuts build times by over 60% and a checklist to audit your own projects.
What you’ll walk away with:
- A clear mental model of how Docker’s layer cache makes decisions.
- The ability to spot and fix the most common cache-busting mistake in any Dockerfile.
- A reusable GitHub Actions workflow snippet that enables remote caching for pull requests.
- A checklist for auditing your existing build processes for caching inefficiencies.
How Does Docker’s Build Cache Actually Work?
Docker builds an image by executing instructions in a Dockerfile sequentially, creating a new layer for each instruction. Docker Layer Caching is the mechanism where Docker reuses a layer from a previous build if the instruction that created it—and all instructions preceding it—have not changed. A single cache miss forces a rebuild of that layer and every subsequent layer.
The most common mistake is copying volatile source code before installing stable dependencies. This invalidates the dependency layer on every single code commit, even a typo fix in a README file.
graph TD
A["FROM node:20-slim"] --> B["WORKDIR /app"]
B --> C["COPY . ."]
C -- "Breaks on ANY file change" --> D["RUN npm install"]
D --> E["RUN npm run build"]
E --> F{Image Ready}
The diagram above shows a fragile build. The COPY . . command is a tripwire; any change invalidates everything that follows.
A cache miss on one layer invalidates all subsequent layers. Structure your Dockerfile from least-frequently-changed to most-frequently-changed instructions.
What Does a Cache-Hostile Dockerfile Look Like?
This pattern is everywhere, and it’s brutally inefficient. It mixes the stable (package.json) with the volatile (your application code), forcing a full npm install on every build triggered by a code change. The result is a 5-minute build that should have taken 30 seconds.
This
Dockerfilerebuilds everything, every time.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Dockerfile.bad FROM node:20-slim WORKDIR /app # Anti-pattern: Copy everything at once # This invalidates the cache on every source code change. COPY . . # This step re-runs on every commit, even if package.json hasn't changed. RUN npm install --frozen-lockfile RUN npm run build # ... final stage to run the app
When you run this in a typical CI pipeline, the log for a simple code change shows no cache hits where it matters most. The expensive npm install step runs again, wasting minutes and compute credits.
1
2
# Simulating a second build after a minor code change
docker build -t my-app:bad -f Dockerfile.bad .
Click to see the painful build log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
=> [internal] load build definition from Dockerfile.bad
=> => transferring dockerfile: 216B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/node:20-slim
=> [internal] load build context
=> => transferring context: 1.2MB
=> [1/4] FROM docker.io/library/node:20-slim
=> CACHED [2/4] WORKDIR /app
=> [3/4] COPY . .
=> [4/4] RUN npm install --frozen-lockfile
# npm output scrolls for 3-5 minutes...
added 1247 packages, and audited 1248 packages in 3m 47s
=> CANCELED [5/4] RUN npm run build
Notice that COPY . . caused a cache miss, forcing the lengthy npm install to run again. This is the bottleneck we need to eliminate.
The
COPY . .command is a code smell in most application Dockerfiles. Be specific about what you copy and when.
How Do You Refactor a Dockerfile for Optimal Caching?
You fix this by splitting the COPY instruction into two parts. First, copy only the dependency manifests (package.json, package-lock.json), then run the install. After that, copy the rest of your source code. This isolates the expensive, slow-moving dependency layer from the fast-moving code layer.
Here’s the change. It’s small, but the impact is massive.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- Dockerfile.bad
+++ Dockerfile.good
@@ -4,11 +4,13 @@
WORKDIR /app
-# Anti-pattern: Copy everything at once
-# This invalidates the cache on every source code change.
-COPY . .
+# Copy only the dependency manifests first
+COPY package*.json ./
-# This step re-runs on every commit, even if package.json hasn't changed.
+# This layer is now only rebuilt when package.json or package-lock.json changes.
RUN npm install --frozen-lockfile
+# Now copy the source code
+COPY . .
+
RUN npm run build
This simple reordering creates a more intelligent caching boundary. The dependency installation is now insulated from code changes.
graph TD
subgraph "Dependency Layer (Stable)"
A["FROM node:20-slim"] --> B["WORKDIR /app"]
B --> C["COPY package*.json ./"]
C --> D["RUN npm install"]
end
subgraph "Source Layer (Volatile)"
D -- "Cache Hit!" --> E["COPY . ."]
E -- "Rebuilds quickly" --> F["RUN npm run build"]
end
F --> G{Image Ready}
To make this work in a stateless CI environment like GitHub Actions, you need to use a remote cache. The docker/build-push-action supports this out of the box.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.good
push: false # Don't push image in this example
cache-from: type=gha
cache-to: type=gha,mode=max
With this workflow and the refactored Dockerfile, the second build is drastically faster. The log will explicitly show a cache hit for the npm install step.
1
2
3
4
5
6
7
8
9
# Second build with the good Dockerfile
...
=> [3/5] COPY package*.json ./
=> [4/5] RUN npm install --frozen-lockfile
=> => CACHED
=> [5/5] COPY . .
=> [6/5] RUN npm run build
...
build finished in 32s
A build that took nearly four minutes now takes 30 seconds. That’s an 87% reduction in wait time, just by reordering two lines.
Your Dockerfile Caching Checklist
-
Is
COPY package*.json ./(or equivalent for your language) a separate, early step? -
Does
RUN npm install(orpip install,bundle install, etc.) happen immediately after that manifestCOPY? -
Is the
COPY . .for your source code placed after the dependency installation? - Are you using a multi-stage build to keep the final image clean?
-
Is your CI/CD pipeline configured with a remote cache (
type=gha,type=s3, etc.)?
Isolate your dependency installation into its own cacheable segment by copying only the manifest files first.
Are Multi-Stage Builds Just for Image Size?
No, multi-stage builds also improve caching and security by strictly separating build-time concerns from runtime concerns. A multi-stage build is a Dockerfile feature that uses multiple FROM instructions to create intermediate build environments, with only the essential artifacts being copied into the final, lean image.
This practice, which is foundational to creating slim, secure containers, is covered in more detail in our guide on Docker & Kubernetes: Advanced Orchestration Patterns. The build stage can be heavily cached, including all your dev dependencies and compilers, while the final stage is just the compiled code and a minimal runtime. Since Docker 20.10, the highly efficient BuildKit engine is the default builder, making these layered builds faster than ever.
As build systems become more integrated, these fundamental optimizations are crucial. Getting your Dockerfile right is a prime example of specialization within a broader trend of DevOps tooling consolidation.
Use multi-stage builds to not only shrink your final image but also to create a clean caching boundary between your build environment and your runtime environment.
Bottom Line
Stop tolerating slow CI builds. The vast majority of the time, the problem isn’t the runner—it’s a poorly structured Dockerfile that thwarts the cache at every turn. Reordering your COPY instructions is the highest-leverage optimization you can make. Do it today and give your team back hours of waiting time each week.
FAQ
What’s the difference between Docker layer cache and GitHub Actions cache?
Docker layer cache operates on the instructions within your Dockerfile, reusing image layers. The GitHub Actions cache action is for caching files and directories on the CI runner itself, like ~/.m2 or node_modules, which is useful for non-Docker builds. For Docker, you want the build-push-action’s built-in caching (cache-from/cache-to), which directly manages Docker image layers.
Does COPY . . always invalidate the cache?
It invalidates the cache if any file in the build context has changed since the last build. Docker computes a checksum of the files being copied; if that checksum differs, it’s a cache miss. This is why it’s so fragile and should be used as late as possible in your Dockerfile.
How do I force a full rebuild without cache in GitHub Actions?
The simplest way is to add the no-cache: true input to the docker/build-push-action.
1
2
3
4
5
- name: Build without cache
uses: docker/build-push-action@v6
with:
# ... other options
no-cache: true
Why use a multi-stage build if my image is already small?
Security and dependency management. A multi-stage build ensures your final image contains only what’s needed for runtime. It prevents build tools, compilers, testing libraries, and development dependencies from being packaged into your production container, reducing the attack surface.
When did Docker BuildKit become the default?
BuildKit became the default builder in Docker Engine version 23.0 and Docker Desktop version 20.10. It offers better performance, parallel build execution, and more advanced features like the caching options discussed here. You can confirm you’re using it by setting the DOCKER_BUILDKIT=1 environment variable, though it’s typically on by default now.
Further Reading
- https://docs.docker.com/build/cache/
- https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- https://docs.gitlab.com/ee/ci/pipelines/job_artifacts_and_caching.html
🚀 Ready to get hands-on? Spin up an interactive AI or Kubernetes Sandbox at Aicademy Labs for free.
