Docker layer caching and image size

Two facts explain nearly every slow Docker build and nearly every oversized image. Each instruction in a Dockerfile creates a layer, and a layer's cache is invalidated when its input changes. Layers are stacked and additive, so a file deleted in a later layer still occupies space in the earlier one.

From the first fact: put the instruction that changes rarely above the instruction that changes often. From the second: whatever you want gone has to never enter the image, or has to be left behind by a stage boundary. The Dockerfile, Best Practices and Common Mistakes sections here hold 34 entries between them, and this guide is the order to read them in.

Order your instructions by how often they change

A dependency install is slow and changes rarely. Your source code is fast to copy and changes constantly. If the source is copied before the install, then editing one line of one file invalidates the copy layer, and every layer below it - including the install - has to run again.

The fix is to copy the dependency manifests on their own, install, and only then copy the rest of the source. The install layer's input is now just package.json and the lockfile, so it is reused on every build that did not change a dependency.

That is the single highest-leverage change available in a Dockerfile, and it is why the COPY entry on the sheet tells you to copy manifests before source rather than simply describing the instruction.

The ordering that keeps the install layer cached: manifests, install, then the rest of the source.

Optimize Layer Caching · DK
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# NOT:
# COPY . .
# RUN npm ci

Optimize Layer Caching in Docker · Best Practices

The shape to recognise: a broad COPY . . high in the file invalidates everything under it whenever any file changes.

Accidental Build Cache Invalidation · DK
# Problem - COPY . . before RUN npm ci
COPY . .
RUN npm ci

Accidental Build Cache Invalidation in Docker · Common Mistakes

A layer is additive, so deleting later saves nothing

This is the one that surprises people, because the Dockerfile reads as if it worked. Install build tools in one RUN, remove them in a later RUN, and the image still contains them: the earlier layer holds the files, the later layer only records a deletion on top.

So a cleanup has to happen in the same RUN as the thing it cleans up. apt-get install ... && rm -rf /var/lib/apt/lists/* as one command leaves nothing behind; the same two commands as two instructions leave the whole package cache in the image forever.

The same reasoning applies to secrets, and there the consequence is worse than size. An ENV value or a COPYed credentials file remains readable through docker history and docker inspect even after a later layer deletes it. The sheet's entry on this is unambiguous: use build secrets or runtime environment variables, and treat anything that was ever in a layer as published.

One RUN, install and clean up together. Two RUNs would keep the cache in the image.

Combine RUN Commands · DK
# Good - single layer, cache cleaned:
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Bad - three layers, cache stuck in layer 1:
# RUN apt-get update
# RUN apt-get install -y curl
# RUN rm -rf /var/lib/apt/lists/*

Combine RUN Commands in Docker · Best Practices

A secret in a layer is a published secret, whatever a later layer does.

Baking Secrets into Images · DK
# NEVER do this:
ENV DATABASE_URL=postgres://user:pass@host/db
COPY .env /app/.env

Baking Secrets into Images in Docker · Common Mistakes

Multi-stage builds: what actually gets dropped

A multi-stage build is several FROM statements in one file. The final image contains only the layers of the last stage, plus whatever you explicitly copied into it from earlier ones. Compilers, dev dependencies, test fixtures and source code stay behind.

The compiled-language case is the dramatic one, and the sheet gives it: a Go toolchain of 800MB or more in the build stage, a static binary on a distroless base in the final image, which lands at a couple of megabytes. The same pattern applies to anything with a build step - bundling a front end, compiling TypeScript, building native extensions.

It is also the clean answer to the dev-dependency problem. Install everything in the build stage, run the build, then copy only the production output and a production-only install into the final stage.

Two stages. Only the last one's layers ship.

Multi-Stage Builds · DK
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Multi-Stage Builds in Docker · Dockerfile

The compiled-language version: toolchain in the build stage, binary in the final image.

Use Multi-Stage Builds · DK
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
CMD ["/app/server"]

Use Multi-Stage Builds in Docker · Best Practices

.dockerignore is part of the build, not a tidiness file

Everything in the build context is sent to the daemon before the build starts. Without a .dockerignore, that includes node_modules, the whole .git history, environment files and coverage reports - often hundreds of megabytes that are transferred, then copied into the image by a broad COPY . ..

It also matters for caching, for the same reason the instruction order does. If .git is in the context, then any commit changes the context, and the COPY . . layer is invalidated by work that has nothing to do with the build.

The file sits next to the Dockerfile and uses the same patterns as .gitignore. It is the cheapest of all the changes here: no restructuring, no new stages, one file.

What to exclude, and why each one matters.

.dockerignore - Exclude Build Context · DK
node_modules
.git
*.md
.env
dist
.DS_Store
coverage

.dockerignore - Exclude Build Context in Docker · Dockerfile

Base image choice, and the tag that moves under you

A smaller base is less to download, less to scan and less attack surface, and the sheet's entry on it is careful about the trade: Alpine uses musl rather than glibc, which breaks some native Node modules and Python C extensions, so Debian slim is the safer middle ground when a native dependency is involved.

Pinning is the other half. node:latest is whatever Node is current when the build runs, and even node:20 moves between patch releases. An unpinned base means a build that worked yesterday can fail today with no change on your side - and in CI, a non-reproducible build is a debugging problem that only appears when you are already under pressure.

Pin the full version for anything that matters, and remember the entry's extra point: package manager versions inside the image deserve the same treatment, or apt-get install quietly brings in something new.

  • Order instructions least-changing first; the dependency install goes above the source copy.
  • Clean up inside the same RUN that created the mess.
  • Use a multi-stage build whenever there is a compiler or a dev-dependency tree involved.
  • Write .dockerignore before the first build, not after the image gets big.
  • Pin the base image to a full version, and prefer a non-root USER while you are editing the file - see Run as Non-Root User.
  • docker history <image> shows which layer the size came from.

The size ladder, with the musl caveat attached.

Use Small Base Images · DK
# Instead of:
FROM node:20          # ~1GB
# Use:
FROM node:20-alpine   # ~130MB
FROM python:3.12-slim # ~150MB vs 900MB for full

Use Small Base Images in Docker · Best Practices

An unpinned tag is a build that can change without you.

Not Pinning Image Versions · DK
# Problem:
FROM node:latest
FROM python
FROM nginx

Not Pinning Image Versions in Docker · Common Mistakes

Questions people ask

Why does my Docker build reinstall dependencies every time?

Because the source is copied into the image before the install runs, so any source change invalidates the install layer's cache. Copy the manifests, install, then copy the rest - see Optimize Layer Caching.

Why is my image still large after I deleted the files?

Because layers are additive. The deletion is recorded in a new layer; the files are still in the earlier one. Remove them inside the same RUN that created them, or leave them behind a stage boundary in a multi-stage build.

What does a multi-stage build actually remove?

Everything from the earlier stages that you do not explicitly copy forward - compilers, dev dependencies, source code, test files. The final image is the last FROM stage plus what you copied into it.

Is it safe to put a secret in ENV if I unset it later?

No. ENV values and copied files stay readable through docker history and docker inspect, because they exist in a layer. Use build secrets or pass the value at runtime - the sheet's entry is explicit about this.

Should I use Alpine for a smaller image?

Often, but test it. Alpine uses musl instead of glibc, which can break native Node modules and Python C extensions. Debian slim is the safer middle ground when native dependencies are in play.

Where this comes from

Every code block above is rendered from this site’s own entry data at build time, with a link to the entry it came from, and every count in the prose is derived from the same file. How this cheat sheet is organized explains the structure and says what the corpus does not cover.

Keep going

More guides