Docker105 min total · 19 parts
Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile
Part 5 of 19 · ~4 min
Images Are Made of Layers, and Layers Are Cached
Each line of a Dockerfile — a FROM here, a COPY or RUN there — leaves behind a permanent, frozen layer, and Docker fingerprints each one by hashing both what it contains and everything stacked below it. Rebuild the image, and Docker walks the instruction list checking those fingerprints against what it already has cached — right up until it hits the first one that no longer matches. Every instruction from that point on has to run again, because its fingerprint was never independent to begin with; it was always partly a function of everything above it.
Here's the web Dockerfile as you first wrote it, and it's worth reading closely because the order is the whole lesson:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
For the first two weeks this was fine, because the whole team was small and nobody minded a slow build. Then Priya changes one line of CSS, runs docker build ., and watches npm install run all over again — every single time, for every single change, no matter how trivial. The reason is right there in the order: COPY . . happens before npm install, so any change to any file — the CSS, a comment, the receipt-parsing logic, anything at all — invalidates that COPY layer's fingerprint, and everything downstream of it, the dependency install included, has no cached fingerprint left to fall back on and has to run fresh.
The fix is to separate what changes rarely from what changes on every commit:
FROM node:20
WORKDIR /app
# copy only the manifest first — this is what actually decides whether
# the install step below gets to reuse its cached layer
COPY package.json package-lock.json ./
RUN npm ci
# now the rest of the code, which changes on nearly every commit
COPY . .
CMD ["node", "server.js"]
package.json barely changes. With it copied in first, npm ci — a clean, reproducible install — stays cached across almost every build: Docker sees the same manifest content, hits the cache, and skips reinstalling entirely. Priya's CSS-only build now finishes in about four seconds instead of the better part of a minute, because only the final COPY . . layer, which is cheap, actually reruns.
The rule generalizes cleanly: put whatever changes rarely near the top of the file, and whatever changes constantly near the bottom. The base image and any system packages belong at the very top, since those might move once a quarter if that. Whatever installs the project's dependencies comes next. The application's own source is the last thing copied in, because it's the one part of the file guaranteed to be different on practically every commit you make.
Layers stack via a union filesystem, which is really just a way of saying each one only records its own delta against whatever's underneath it — nothing gets duplicated, and Docker flattens the whole stack into what looks, from inside a running container, like a completely normal single filesystem. That overlay behavior has a consequence almost nobody expects the first time: if a file gets removed in some subsequent layer, the image doesn't shrink by so much as a byte. Whatever wrote that file already committed those bytes permanently, back when it ran — the removal step only leaves behind a note telling the overlay to pretend the path is empty, which hides the file from anything running inside the container without freeing a single byte underneath.
You find this out firsthand a few weeks later, trying to shrink the worker image, which briefly needed a large bundle of sample receipt PDFs for a local fixture test:
# What you tried first — by the time this RUN finishes, the archive's
# bytes are permanently part of a layer; the next RUN can only hide them.
RUN wget https://internal.example.com/fixtures/sample-receipts.tar.gz -O /tmp/fixtures.tar.gz
RUN tar -xzf /tmp/fixtures.tar.gz -C /app/fixtures
RUN rm /tmp/fixtures.tar.gz # image is still bigger by the archive's size
# What actually works — download, extract, and clean up inside one RUN,
# so the tarball never gets its own permanent layer
RUN wget https://internal.example.com/fixtures/sample-receipts.tar.gz -O /tmp/fixtures.tar.gz \
&& tar -xzf /tmp/fixtures.tar.gz -C /app/fixtures \
&& rm /tmp/fixtures.tar.gz
You'll come back to this image-size habit later, because right now nobody on the team is actually watching how big these images are getting, and that's about to become a real problem.