Most people learn Docker by copying a Dockerfile that works, shipping it, and moving on. That's fine until the day a build that used to take twenty seconds suddenly takes four minutes, or an image that should be small balloons past a gigabyte, or "it worked on my machine" turns into "it worked in the pipeline yesterday." Almost every one of those surprises traces back to one thing people never quite internalized: what a Docker image actually is.
So let me slow down and build the mental model properly. Once you can picture an image as a stack of layers, the caching behavior stops feeling like magic and the size problems stop feeling random. This is less about the tool and more about understanding the thing you're already using every day.
An image is a stack of read-only layers
Here's the whole idea in one sentence: a Docker image is an ordered stack of read-only filesystem layers, and a container is that same stack with one thin writable layer added on top.
Each layer is a diff — a set of filesystem changes relative to the layer below it. Add a file, that's a change in a layer. Delete a file, that's also recorded as a change in a new layer (more on why that matters later). When you stack all those diffs and flatten them with a union filesystem, you get the final root filesystem your process sees.
When you run a container, Docker doesn't copy the image. It takes those read-only layers, adds a small writable layer on top, and points the container at the combined view. That's why you can start ten containers from one image almost instantly — they all share the same underlying read-only layers, and each only pays for its own writable scratch space. It's copy-on-write: a file is only duplicated into the writable layer at the moment the container modifies it.
This is the first "aha" for a lot of people. The image is immutable. Anything your running container writes lives in that disposable top layer and vanishes when the container is removed — which is exactly why you reach for volumes when you need data to survive.
Which lines in a Dockerfile create layers
Not every instruction creates a layer, and knowing which do is where the practical payoff starts. Take this:
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
The instructions that change the filesystem create layers: FROM (the base image, itself a stack of layers), COPY, and RUN. Those are the ones that add real content.
Instructions like WORKDIR, EXPOSE, CMD, ENV, and LABEL mostly write metadata rather than filesystem content. In modern BuildKit they don't add meaningful filesystem weight — they're bookkeeping the image carries around. The mental shortcut I use: FROM, COPY, ADD, and RUN are the lines that cost you. Those are where bytes and build time actually accumulate.
You can see the stack yourself:
docker history myapp:1.4.2
That prints each layer, the instruction that created it, and its size. When an image is mysteriously huge, this is the first command I run. It usually points straight at the offender — a RUN that installed a toolchain, or a COPY that pulled in a node_modules you didn't mean to ship.
How the build cache works
Docker builds are cached layer by layer, and the rule is simple once you say it out loud: for each instruction, Docker checks whether it has already built that exact layer before. If yes, it reuses the cached layer and moves on. The moment one instruction misses the cache, every instruction after it is rebuilt from scratch, because each layer depends on the one beneath it.
What counts as a cache hit depends on the instruction:
- For
RUN, the cache key is the literal command string. Changenpm citonpm ci --omit=devand it's a miss. - For
COPYandADD, Docker hashes the contents of the files being copied. If any copied file changed, that layer and everything after it rebuilds. - For
FROM, pulling a new version of the base image invalidates everything downstream.
That cascade is the entire game. Cheap, stable steps belong early so they stay cached; expensive steps that depend on frequently-changing input belong as late as you can put them.
Why instruction order matters
Look back at that Dockerfile and notice the deliberate split:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
We copy the dependency manifests first, install dependencies, and only then copy the rest of the source. That ordering is doing real work. Your package.json changes rarely; your application code changes on nearly every commit. By copying the manifests separately, the expensive npm ci layer stays cached across all the commits where you only touched source code.
Flip it around and you get the classic mistake:
# Don't do this
COPY . .
RUN npm ci --omit=dev
Now any change to any source file invalidates the COPY . . layer, which invalidates the RUN npm ci layer beneath your feet — so you reinstall every dependency on every build. Same commands, wildly different build times, purely because of order.
The rule of thumb that's served me well: order instructions from least-likely-to-change to most-likely-to-change. Base image and system packages up top, dependency install in the middle, your application code last. After enough years watching CI queues, you start ordering Dockerfiles for cache friendliness by reflex.
If you want a second pair of eyes on ordering and other common footguns before you commit, there's a free . But honestly, the model above is 90% of it. Save the pattern, not just the snippet.
SOCIAL SHARE CARD GENERATOR