CodeOath
← All posts
Docker65 min total · 19 parts

Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile

Contents — Part 5 of 19: Images Are Made of Layers, and Layers Are Cached
Part 5 of 19 · ~2 min

Images Are Made of Layers, and Layers Are Cached

Each instruction in a Dockerfile (FROM, COPY, RUN, ...) creates a new, immutable layer, identified by a hash of its content and the layer before it. Docker caches layers and reuses them across builds — but only up to the first instruction that changed. Everything after that point gets rebuilt from scratch, because each layer's cache key depends on everything above it.

FROM node:20-alpine
WORKDIR /app

# Copy only the manifest first...
COPY package.json package-lock.json ./
RUN npm ci

# ...then the rest of the code
COPY . .

CMD ["node", "server.js"]

This ordering is deliberate, not arbitrary. package.json changes rarely, so npm ci (a full dependency install) stays cached across most builds — Docker sees the same package.json/package-lock.json content, hits the cache for that layer, and skips reinstalling. If you instead did COPY . . before npm ci, any code change — even a one-line fix in an unrelated file — would invalidate that COPY layer's cache, and every layer after it (including the full npm ci) would have to rerun.

A useful mental model: order your Dockerfile from least-frequently-changing to most-frequently-changing. Base image and system packages first, then dependency manifests and their install step, then application code last, since code is what changes on nearly every commit.

Layers stack on top of each other using a union filesystem — each layer only stores the diff from the layer below it, and at runtime they're overlaid to look like one coherent filesystem. This is also why a file deleted in a later layer doesn't actually shrink the image — the file still physically exists in the earlier layer; the later layer just adds a marker that hides it. Deleting a large file you added earlier in the same Dockerfile, in a later RUN, does not reduce image size — it needs to happen in the same RUN instruction that created it, or via a multi-stage build (below), to avoid it being shipped in an earlier layer.

# Bad — the large file is already committed to a layer; deleting it later
# doesn't remove those bytes from the image, it just hides them.
RUN wget https://example.com/large-archive.tar.gz -O /tmp/archive.tar.gz
RUN tar -xzf /tmp/archive.tar.gz -C /app
RUN rm /tmp/archive.tar.gz          # image is still bigger by the archive's size

# Better — download, extract, and clean up within one RUN, one layer
RUN wget https://example.com/large-archive.tar.gz -O /tmp/archive.tar.gz \
    && tar -xzf /tmp/archive.tar.gz -C /app \
    && rm /tmp/archive.tar.gz