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 Dockerfile validator that runs entirely in your browser and flags the obvious ones. Handy for a quick sanity check.
Tags versus digests
Now the part that quietly bites people in production. When you write FROM node:20-slim, 20-slim is a tag — a human-friendly, mutable pointer. The maintainers can and do republish that tag over time as they patch the base. So node:20-slim today and node:20-slim next month can be two genuinely different images, even though the line in your Dockerfile never changed.
A digest is the other option, and it's content-addressed:
FROM node:20-slim@sha256:6f1e...c93a
That sha256:... is a cryptographic hash of the exact image content. Pin to a digest and you get the same bytes every time, forever — nobody can move it out from under you. You look them up with:
docker inspect --format='{{index .RepoDigests 0}}' node:20-slim
The tradeoff is honest: tags give you convenient rolling updates but weaker reproducibility; digests give you reproducibility but you now own the job of updating them when you want patches. In my experience the sweet spot for anything that matters is to pin base images by digest and let a bot (Renovate, Dependabot, whatever you use) propose digest bumps on a schedule. You get reproducible builds and a clear, reviewable trail of every base-image change. When you've been burned by a "nothing changed but the build broke" incident, digest pinning stops looking like paranoia and starts looking like basic hygiene.
Where registries fit in
A registry is just the storage and distribution layer for these images — Docker Hub, GitHub Container Registry, registry.example.com, your cloud provider's version, whatever. docker push uploads your layers, docker pull downloads them.
Because everything is content-addressed layers, the registry is efficient about it. If three of your images share the same base, that base is stored once and pulled once; only the layers that differ move over the wire. That layer-sharing is also why keeping your frequently-changing content in the last layers pays off at pull time too, not just build time — a small top layer is all that has to travel when you ship a code-only change.
Wrapping up
Here's the whole model in a few lines: an image is an ordered stack of read-only layers; a container is that stack plus a thin writable layer; FROM, COPY, ADD, and RUN are the instructions that create layers; the cache reuses a layer until something changes, and a miss cascades to everything below it; order your instructions stable-to-volatile; and pin by digest when reproducibility matters more than convenience.
Internalize that and the rest of Docker gets a lot less mysterious — you'll read a docker history output and know exactly why the image looks the way it does. If you want the longer-form version of this mental model with more diagrams, I wrote a companion explainer on what a Docker image really is. But honestly, the model above is 90% of it. Save the pattern, not just the snippet.
Top comments (0)