Articles

Docker Image Layers and Caching Explained

Docker images are stacks of read-only layers cached by content hash. How layer order affects build speed, cache hits, and final image size.

Chisato Chisato · · 4 min read
Server racks with bundled cables

A Docker image isn’t a single file — it’s a stack of read-only layers, each one the filesystem diff produced by a single instruction in a Dockerfile. Understanding how those layers are built, cached, and stacked explains two things that otherwise feel like magic: why some Docker builds take ten seconds and others take ten minutes, and why two images with almost identical contents can differ wildly in size.

What a layer actually is

Every instruction in a Dockerfile that changes the filesystem — RUN, COPY, ADD — produces a new layer: a snapshot of exactly what changed relative to the layer beneath it. FROM, ENV, and a few other instructions don’t create filesystem layers in the same way, but most of the meaningful ones do. Docker stores each layer once, identified by a content hash, and stacks them using a union filesystem so the container sees what looks like one coherent directory tree, even though it’s assembled from many separate read-only pieces at runtime.

FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

Each of COPY package.json..., RUN npm ci, COPY . . produces its own layer. If nothing in a given instruction’s inputs has changed since the last build, Docker reuses the cached layer instead of re-executing the instruction — that’s the entire caching mechanism, and it’s the reason instruction order in a Dockerfile matters far more than it looks like it should.

Why layer order determines cache hits

Docker’s build cache works top to bottom: it checks each instruction against the cache in Dockerfile order, and the moment one instruction misses the cache, every instruction after it must re-run too — even if their own inputs haven’t changed. This is why the Dockerfile above copies package.json and runs npm ci before copying the rest of the application source.

If the order were reversed — copy everything, then run npm ci — changing a single line of application code (which has nothing to do with dependencies) would invalidate the COPY . . layer, and therefore invalidate npm ci too, forcing a full dependency reinstall on every single code change. By copying only the dependency manifest first, the expensive npm ci layer stays cached across every build where dependencies themselves haven’t changed, and only reruns when package.json or the lockfile actually changes.

The general rule: order instructions from least-frequently-changing to most-frequently-changing. Base image and system dependencies first, then application dependencies, then application source last.

Why image size depends on layer history, not just final contents

A common surprise: deleting a file in a later layer doesn’t shrink the image, because the deleted file still physically exists in the earlier layer — the union filesystem just hides it from view inside the running container. The layer where it was added still ships with the image and still counts toward its size, even though nothing in the final filesystem shows it.

# This does NOT reduce image size — the large file
# still exists in the earlier layer.
RUN wget https://example.com/large-archive.tar.gz -O /tmp/archive.tar.gz \
    && tar -xzf /tmp/archive.tar.gz -C /app
RUN rm /tmp/archive.tar.gz

To actually avoid shipping unnecessary bytes, the download, extraction, and cleanup need to happen within a single RUN instruction (so no layer is ever created with the unneeded file present), or via a multi-stage build, where an entire earlier stage — build tools, source archives, intermediate artifacts — is discarded and only the final stage’s output is copied into the image that actually ships.

FROM node:20-slim AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]

The final image never contains the builder stage’s layers at all — only what’s explicitly copied out of it with COPY --from=builder.

Caching in CI, not just locally

Local builds benefit from Docker’s cache automatically, since layers persist on disk between builds. CI runners are often ephemeral, though, starting from a clean environment on every run — which means the cache that made local iteration fast doesn’t exist by default. Most CI platforms solve this with an explicit cache-import/export step that pulls previously built layers back in before the build starts, or by pushing intermediate layers to a registry that later builds can reference. See CI/CD with GitHub Actions for how a pipeline wires this up in practice.

Where this fits with the rest of the container stack

Layer caching is purely a build-time and image-size concern — it doesn’t change how a running container behaves, which is covered in Docker for beginners. Once images are built, Kubernetes vs Docker covers what orchestrates many containers from those images across machines, and immutable infrastructure explains why treating a built image as a fixed, unchangeable artifact — rather than patching a running container in place — is the deployment model most of this layer discipline is ultimately in service of.

The takeaway

A Docker image is a stack of cached, content-hashed layers, and the build cache only helps if instructions that change rarely come before instructions that change often — put dependency installation before source code, not after. Deleting a file in a later layer doesn’t remove its bytes from the image; only a single combined RUN or a multi-stage build that discards the entire stage actually shrinks it. Getting layer order and multi-stage builds right is usually the single biggest lever for both build speed and final image size.

Chisato Chisato · · 5 min read

Kubernetes vs Docker: What's the Difference?

Docker builds and runs containers; Kubernetes orchestrates fleets of them. What each tool does, how they work together, and when Compose is enough.

#Kubernetes #Docker #DevOps
Chisato Chisato · · 2 min read

Docker for Beginners: Containerize Your First App

Docker packages your app and everything it needs into one portable container. Learn the core concepts and ship your first containerized app in minutes.

#Docker #DevOps #Containers