Yasas Banuka
Images, Layers, and Why Your Builds Are Slow
Back to BlogDevOps

Images, Layers, and Why Your Builds Are Slow

May 20, 202610 min read·1,889 words
Share
Docker: From Zero to Production — Part 3 of 7
1Why Docker Exists (And Why VMs Weren't Enough)2What's Actually Happening When You Run a Container3Images, Layers, and Why Your Builds Are SlowCurrent
4Docker Networking: How Containers Actually Find Each OtherComing Soon
5Docker Compose: Reading the File You've Been Copy-PastingComing Soon
6Securing Your Docker Setup (Most People Skip This)Coming Soon
7From Laptop to Production: CI/CD with DockerComing Soon

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

Dockerfile order

FROM node:22-alpine

HIT

Cache HIT (base image unchanged)

COPY . .

MISS

Cache MISS (code changed)

RUN npm install

MISS

Cache MISS (cascaded from above)

Fast Dockerfile

~3-4 seconds per rebuild

Dockerfile order

FROM node:22-alpine

HIT

Cache HIT (base image unchanged)

COPY package.json .

HIT

Cache HIT (deps list unchanged)

RUN npm install

HIT

Cache HIT (deps unchanged)

COPY . .

MISS

Cache MISS (code changed, but instant)

Cache MISS — layer reruns from scratch, all layers below it also miss
Cache HIT — uses stored result instantly

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 invalidated
  • RUN npm install comes 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 hit
  • RUN npm install has nothing changed above it, so it is also a cache hit
  • COPY . . 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

node:22-alpine
npm (all dev deps)
TypeScript compiler
ESLint / test tools
Source code (.ts)
Build output (/dist)

Only /dist copied

Discarded:

node:22-alpine

npm

Source code

Dev dependencies

Build tools

Stage 2: Ship

Final image

nginx:alpine
/dist (compiled output)
~148 MBvs 1,100 MB before
# 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:

LanguageBefore Multi-StageAfter Multi-StageReduction
Node.js1,100 MB~148 MB~87%
Python920 MB~85 MB~91%
Java700 MB~180 MB~74%
Go850 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:

  1. Move dependency installation above your code copy. package.json first, then install, then COPY . .
  2. Add a .dockerignore if you do not have one. It takes two minutes.
  3. 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.

#docker#dockerfile#devops#containers#builds
Share
← All Articles
Yasas Banuka Malavige

Written by

Yasas Banuka Malavige

DevOps Engineer · Building resilient infrastructure, automating pipelines, and documenting the quiet foundations that keep production systems alive.

Docker: From Zero to Production — Part 3 of 7
1Why Docker Exists (And Why VMs Weren't Enough)2What's Actually Happening When You Run a Container3Images, Layers, and Why Your Builds Are SlowCurrent
4Docker Networking: How Containers Actually Find Each OtherComing Soon
5Docker Compose: Reading the File You've Been Copy-PastingComing Soon
6Securing Your Docker Setup (Most People Skip This)Coming Soon
7From Laptop to Production: CI/CD with DockerComing Soon