A slow build is usually a badly ordered Dockerfile, not a slow machine. Each instruction is a layer, the cache is invalidated from the first changed layer onward, and that single rule decides everything.
1. A project with dependencies
mkdir -p ~/labs/docker-cache && cd ~/labs/docker-cache
cat > package.json <<'JSON'
{
"name": "cache-lab",
"version": "1.0.0",
"dependencies": { "express": "4.19.2" }
}
JSON
cat > server.js <<'JS'
const express = require("express");
const app = express();
app.get("/healthz", (_req, res) => res.send("ok\n"));
app.listen(8080, () => console.log("listening on 8080"));
JS2. The wrong order
cat > Dockerfile.slow <<'DOCKER'
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install --omit=dev
CMD ["node", "server.js"]
DOCKER
time docker build -f Dockerfile.slow -t cache-slow . 2>&1 | tail -n 3Change one line of application code and rebuild:
echo '// a comment' >> server.js
time docker build -f Dockerfile.slow -t cache-slow . 2>&1 | tail -n 6npm install ran again. COPY . . came before it, so changing any file invalidated that layer and everything after it — including the dependency install that has nothing to do with your edit.
Verify
3. The right order
cat > Dockerfile <<'DOCKER'
FROM node:20-alpine
WORKDIR /app
# Only the manifest, so this layer survives every source change.
COPY package.json ./
RUN npm install --omit=dev
# Source last: the layer that changes most often is the cheapest to rebuild.
COPY server.js ./
CMD ["node", "server.js"]
DOCKER
docker build -t cache-fast . 2>&1 | tail -n 3
echo '// another comment' >> server.js
time docker build -t cache-fast . 2>&1 \
| grep -E "CACHED|npm install|DONE" | head -n 6npm install is CACHED. The principle generalises to every language: copy the dependency manifest, install, _then_ copy the source. requirements.txt before the Python code, go.mod before the Go, Cargo.toml before the Rust.
Verify
4. Stop sending junk to the daemon
mkdir -p node_modules/.cache && dd if=/dev/zero of=node_modules/.cache/junk \
bs=1M count=40 status=none
mkdir -p .git && dd if=/dev/zero of=.git/pack bs=1M count=20 status=none
docker build -t cache-fast . 2>&1 | grep -i "transferring context" | tail -n 1
cat > .dockerignore <<'IGNORE'
node_modules
.git
*.log
Dockerfile*
.dockerignore
IGNORE
docker build -t cache-fast . 2>&1 | grep -i "transferring context" | tail -n 1The build context is everything in the directory, sent to the daemon before the build starts. Without .dockerignore you are uploading node_modules and .git on every build — and worse, COPY . . would bake them into the image, including whatever is in .git.
Verify
5. Inspect the layers you produced
docker history cache-fast --human --format '{{.Size}}\t{{.CreatedBy}}' | head -n 8
docker image inspect cache-fast --format '{{len .RootFS.Layers}} layers'docker history shows each layer's size and the instruction that made it. This is how you find the surprise: a RUN apt-get install with no cleanup, or a COPY that brought in more than intended.
A layer's size is the _delta_. Deleting a file in a later layer does not shrink the image — the bytes are still in the earlier layer. That is why cleanup has to happen in the same RUN:
cat > Dockerfile.cleanup <<'DOCKER'
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
DOCKER
docker build -f Dockerfile.cleanup -t cache-clean . >/dev/null 2>&1
docker image ls cache-clean --format '{{.Size}}'Verify
6. Cache what the package manager downloads
cat > Dockerfile.mount <<'DOCKER'
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install --omit=dev
COPY server.js ./
CMD ["node", "server.js"]
DOCKER
docker build -f Dockerfile.mount -t cache-mount . 2>&1 | tail -n 3A BuildKit cache mount persists a directory _between_ builds without putting it in the image. Even when the dependency layer is invalidated by a real manifest change, the downloads are still local. This is the difference between a two-minute and a twenty-second CI build.
Verify
The four rules
- Copy the dependency manifest and install before copying source.
- Write a
.dockerignore. Always. - Clean up inside the same
RUNthat created the mess. - Use
--mount=type=cachefor package manager downloads.
Clean up
docker image rm cache-slow cache-fast cache-mount cache-clean 2>/dev/null
cd ~ && rm -rf ~/labs/docker-cacheWhere this goes next
Builds are fast. Next: stopping one container from taking the whole machine down.