You make a small change. One line in your app code.
You run docker build.
And then you wait. Two minutes. Three minutes. Dependencies re-downloading. Packages reinstalling. All of it for a single line change that had nothing to do with any dependency.
Sound familiar?
It is not a slow machine. It is a Dockerfile that does not understand how Docker actually thinks. And once you see how Docker thinks, you will never write a slow build again.
An Image Is Not What You Think It Is
Most people picture a Docker image as one big file. Like a zip archive of their app and everything it needs. Convenient, portable, self-contained.
That mental model is close enough for your first day with Docker. But it breaks down the moment you try to understand why builds are slow, why your image is 900MB, or why docker pull sometimes takes seconds and other times takes minutes.
A Docker image is a stack of read-only layers. Each layer is just a set of filesystem changes: files added, modified, or deleted. Stack them all on top of each other and you get the complete filesystem your container will see when it starts.
Think of it like a geological cross-section. The ground beneath your feet is not one solid block. It is millions of years of sediment laid down one era at a time. Each layer records a specific period. Change one era and everything above it shifts, but everything below stays exactly as it was.
Docker images work the same way. Each instruction in your Dockerfile that touches the filesystem creates a new layer. And those layers stack, permanently, from bottom to top.
# Layer 1: base operating system
FROM node:22-alpine
# Layer 2: dependency manifest
COPY package.json .
# Layer 3: installed dependencies
RUN npm install
# Layer 4: your application code
COPY . .
This layered structure is what makes Docker images efficient and shareable. Ten different apps built on node:22-alpine all share that base layer on disk. Docker stores it once and uses it everywhere.
The Rule That Explains Everything
When a layer changes, every layer that comes after it gets rebuilt from scratch.
Not just that layer. Every single layer below it in your Dockerfile.
This is called layer invalidation. And it is both the most powerful optimization in Docker and the most common trap developers fall into.
Layer Cache: Slow vs Fast Dockerfile
After a single code change to index.js
Slow Dockerfile
~3-4 minutes per rebuild
FROM node:22-alpine
HITCache HIT (base image unchanged)
COPY . .
MISSCache MISS (code changed)
RUN npm install
MISSCache MISS (cascaded from above)
Fast Dockerfile
~3-4 seconds per rebuild
FROM node:22-alpine
HITCache HIT (base image unchanged)
COPY package.json .
HITCache HIT (deps list unchanged)
RUN npm install
HITCache HIT (deps unchanged)
COPY . .
MISSCache MISS (code changed, but instant)
Watch what happens with this typical Dockerfile:
FROM node:22-alpine
COPY . . # copies ALL your code
RUN npm install # installs all dependencies
You change one line in your index.js. Just one line. What happens?
COPY . .detects that files changed, so the layer is invalidatedRUN npm installcomes after it, so it also runs from scratch- npm downloads and installs every dependency again
- Build time: 3 to 4 minutes. For one line change.
Now look at this version:
FROM node:22-alpine
COPY package.json . # copies only the dependency list
RUN npm install # installs dependencies
COPY . . # copies your actual code last
Same one-line change to index.js. What happens now?
COPY package.json .has not changed, so it is a cache hitRUN npm installhas nothing changed above it, so it is also a cache hitCOPY . .sees the code change and rebuilds, but this is nearly instant- Build time: 3 to 4 seconds.
Same app. Same dependencies. The only difference is the order of two lines.
Put what changes least at the top. Put what changes most at the bottom.
Your code changes dozens of times a day. Your dependencies change maybe once a week. So dependencies should always sit above your code copy in any Dockerfile you write, regardless of language:
Node.js -> COPY package.json first, then RUN npm install
Python -> COPY requirements.txt first, then RUN pip install
Java -> COPY pom.xml first, then RUN mvn dependency:go-offline
Go -> COPY go.mod go.sum first, then RUN go mod download
Same principle. Every language. Every project.
Try It Right Now
If you have Docker installed, do this. It takes about three minutes and you will feel the difference directly.
Create a Dockerfile.slow:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm init -y && npm install express
And a Dockerfile.fast:
FROM node:22-alpine
WORKDIR /app
COPY package.json* ./
RUN npm init -y && npm install express
COPY . .
Build both. Note the time. Then make a tiny change to any file, even just add a comment. Build both again.
The first rebuild takes almost as long as the initial build. The second rebuild is nearly instant. Same result, completely different experience. That is the cache working for you instead of against you.
The Hidden Truth About Image Layers
Here is the lesser-known angle that changes how you think about Docker images entirely.
Docker images are internally structured almost identically to Git.
In Git, every commit is identified by a SHA256 hash of its contents. Change one character anywhere and you get a completely different hash. Branches and tags are just human-readable pointers to specific commit hashes. The actual data is content-addressable, stored and retrieved by what it contains, not by a name.
Docker image layers work exactly the same way.
Every layer is identified by the SHA256 hash of its contents. Pull the nginx image and you will see something like:
sha256:a1b2c3d4e5f6... <- not a name, a fingerprint of the actual content
This means two completely different images that happen to share the same base OS layer share that layer physically on disk. Docker stores it once. Pulls it once. Uses it everywhere.
It also means Docker can detect tampering. If someone intercepts your image pull and modifies a layer, the hash will not match and Docker knows immediately. This is the same guarantee that makes Git commits trustworthy.
And just like Git tags point to specific commits, Docker tags point to specific image manifests. When you push myapp:latest, you are creating a pointer. When you push myapp:latest again with a new build, the pointer moves. The old content is still there but the tag now points somewhere new.
This is why version tags matter so much in production. myapp:1.0.0 is an immutable pointer forever. myapp:latest is a moving target that means whatever was last pushed.
The Factory Floor Problem
Now let's talk about image size, because this is where most production images quietly go wrong.
Think about a car factory. The factory floor is massive: heavy machinery, welding robots, painting rigs, assembly lines, quality control stations. Every tool you need to build a car is there.
But when the car rolls off the line and goes to a customer, do they get the factory too? Obviously not. They get the car. Just the car.
Most Docker images ship the factory.
To build a Node.js app you need the Node.js runtime, npm, all your source code, the TypeScript compiler, ESLint, test frameworks, and all your dev dependencies.
To run that same app in production you need the compiled output files, maybe a web server, and production dependencies only. That is it.
A naive Dockerfile ships everything. The factory floor, the tools, the raw materials, the mess. The result is an image that is 900MB, sometimes 1GB or more. Slow to pull, slow to deploy, and a much larger attack surface.
Multi-stage builds solve this. You use multiple FROM statements in one Dockerfile, where each one starts a completely fresh environment. The final stage copies only what it needs from the previous stages.
Multi-Stage Build
Stage 1: Builder
AS builder
Only /dist copied
Discarded:
node:22-alpine
npm
Source code
Dev dependencies
Build tools
Stage 2: Ship
Final image
# Stage 1: The Factory
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
# Stage 2: The Car
FROM nginx:alpine
COPY --from=builder /dist /usr/share/nginx/html
# nginx + your compiled files. Nothing else.
The final image contains zero trace of Node.js, npm, your source code, or your dev dependencies. Just nginx and the compiled output.
The size difference is not subtle. Here are real numbers from production:
| Language | Before Multi-Stage | After Multi-Stage | Reduction |
|---|---|---|---|
| Node.js | 1,100 MB | ~148 MB | ~87% |
| Python | 920 MB | ~85 MB | ~91% |
| Java | 700 MB | ~180 MB | ~74% |
| Go | 850 MB | ~12 MB | ~99% |
Go's 99% reduction deserves a special mention. Go compiles to a single static binary with no external dependencies. The entire runtime is baked in. So the final image can be just that one binary sitting on a minimal base with no OS tools, no package manager, nothing. 850MB of build environment produces a 12MB production image.
Two Common Mistakes Worth Fixing Today
Mistake 1: Not using .dockerignore
When you run docker build, Docker sends your entire project directory to the daemon as the build context. Everything in the folder. Including node_modules (often hundreds of MB), your .git history, test fixtures, and log files.
Even if you never explicitly COPY these files, they inflate your build context and slow down every single build. The fix is a .dockerignore file in your project root:
node_modules
.git
*.log
.env
dist
coverage
Same concept as .gitignore but for Docker builds. It takes two minutes to add and can save tens of seconds per build.
Mistake 2: Splitting apt-get update and apt-get install
This one is subtle:
# Wrong: two separate layers
RUN apt-get update
RUN apt-get install -y curl
# Right: one layer, always fresh
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
When they are on separate lines, apt-get update can get cached from a week ago while apt-get install runs fresh, resulting in package version mismatches and mysterious build failures. Combining them ensures they always run together. The rm -rf /var/lib/apt/lists/* at the end cleans up the package index files so they do not bloat your layer.
Three Things to Do Right Now
Docker images are not zip files. They are stacks of layers, each identified by a fingerprint of its content, each cached independently.
The layer cache is your most powerful build optimization, and the order of your Dockerfile instructions is the single biggest lever you have over it.
Take any Dockerfile you are working on right now and check these three things:
- Move dependency installation above your code copy.
package.jsonfirst, then install, thenCOPY . . - Add a
.dockerignoreif you do not have one. It takes two minutes. - Check if your production image needs multi-stage builds. If it is over 500MB, it almost certainly does.
What's Next
In the next article we are going into Docker networking.
Why does localhost break inside a container? How do your containers actually find and talk to each other by name? What is really happening when you do -p 8080:80?
If you have ever seen connection refused between two containers that should be talking to each other, that article will make it completely clear.
What is the worst slow build you have ever sat through? How long before you found the Dockerfile ordering issue? Drop it in the comments.



