Skip to content
Docker
Lab 5 of 11·35mIntermediate

Make builds fast with layers and cache

Watch a one-line source change rebuild everything, then reorder the Dockerfile so it rebuilds almost nothing.

You need

  • Docker Engine 24+ with BuildKit (default since 23)

Do first

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"));
JS

2. 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 3

Change 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 6

npm 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

docker build -f Dockerfile.slow -t cache-slow . 2>&1 | grep -c "CACHED" || echo 0 # few or no CACHED lines after a source change

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 6

npm 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

echo '// x' >> server.js && docker build -t cache-fast . 2>&1 | grep -c CACHED # 3 or more layers reused

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 1

The 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

test -f .dockerignore && grep -c node_modules .dockerignore # 1

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

docker history cache-fast --format '{{.CreatedBy}}' | grep -c "npm install" # 1

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 3

A 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

docker image inspect cache-mount --format '{{len .RootFS.Layers}}' # a layer count — the cache mount is not one of them

The four rules

  1. Copy the dependency manifest and install before copying source.
  2. Write a .dockerignore. Always.
  3. Clean up inside the same RUN that created the mess.
  4. Use --mount=type=cache for package manager downloads.

Clean up

docker image rm cache-slow cache-fast cache-mount cache-clean 2>/dev/null
cd ~ && rm -rf ~/labs/docker-cache

Where this goes next

Builds are fast. Next: stopping one container from taking the whole machine down.