A well-written Dockerfile is the heart of the recipe that makes your app run the same way everywhere. Yet most Dockerfiles produce images that are bigger than they need to be and rebuild everything on the smallest change. In this guide we'll work through Docker's layer and cache model step by step, and build images that are smaller, faster and reproducible.
The layer model: every instruction is a layer
An image is made of stacked, read-only layers. Each instruction in a Dockerfile — FROM, RUN, COPY, ADD — produces a new layer. Docker caches these layers: if an instruction and its inputs haven't changed, Docker doesn't recompute the layer, it reuses the cached one.
The crucial point: when a layer changes, the cache for every layer below it is invalidated too. So putting things that change often near the end of the Dockerfile, and things that rarely change near the top, directly affects build time. Order is half the battle.
Let's start with a simple Dockerfile
A typical but flawed example for a Node.js app looks like this:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
This works, but there's a problem: because COPY . . copies the whole project, changing a single line of source code invalidates the cache of the RUN npm install layer below it, so dependencies are reinstalled every time. Yet if package.json hasn't changed, dependencies don't need to change.
Using the cache correctly: separate dependencies
The fix is to copy only the dependency manifests first and install them, leaving application code for last:
FROM node:20
WORKDIR /app
# Only the dependency files first
COPY package.json package-lock.json ./
RUN npm ci
# Then the application code
COPY . .
CMD ["node", "server.js"]
Now only the final COPY . . layer reruns when source code changes; the npm ci layer comes from cache. As long as package-lock.json hasn't changed, dependencies aren't reinstalled. This small reordering can cut build time from minutes to seconds.
Using npm ci instead of npm install is also deliberate: ci follows the lock file exactly, is deterministic and is more reliable for CI environments.
Keep junk out with .dockerignore
COPY . . copies everything — node_modules, .git, log files, your local .env... These bloat the image and needlessly bust the cache. Add a .dockerignore file at the project root:
node_modules
.git
.gitignore
Dockerfile
*.log
.env
dist
coverage
This file works like .gitignore and lists paths that should never enter the build context (the files sent to the Docker daemon). A smaller context means faster builds and a cleaner image.
Multi-stage builds: drop the build tools
The tools needed to build an app (compilers, dev dependencies) aren't needed to run it. A multi-stage build lets you build in one stage and carry only the result into a small final image:
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# A thin final stage carrying only the production output
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Here --from=build copies only the compiled dist folder into the second stage. The final image contains no build tools or dev dependencies, which often halves the image size or more. Picking a thin base image like node:20-slim also reduces size and attack surface.
Practical rules and common mistakes
- Combine RUN commands: Each
RUNis a layer. Chain package installs with&&and clean up in the same layer; for example on Debian-based imagesapt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*. - Pin versions:
FROM node:20beatsFROM node:latest; use a specific version for reproducibility. - Write CMD and ENTRYPOINT in exec form:
CMD ["node", "server.js"]forwards signals (SIGTERM) correctly compared to the shell form, allowing a clean shutdown. - Don't run as root: In production, define a
USERso the container runs as an unprivileged user. - Use WORKDIR: Prefer
WORKDIRovercd; it's readable and persists across layers.
Building and inspecting the image
Once the Dockerfile is ready, building the image and checking its size is straightforward:
docker build -t my-app:1.0 .
docker images my-app
docker history my-app:1.0
docker history shows, layer by layer, which instruction takes how much space; it's the most practical tool for finding what bloats your image. With BuildKit (the default in modern Docker) the build output also shows which steps were CACHED.
Frequently Asked Questions
Should I use a Dockerfile or Compose?
They do different jobs. A Dockerfile defines how a single image is built; docker compose defines and runs multiple containers together (an app plus a database, say). Compose usually references your Dockerfile via build: — so one doesn't replace the other, they're used together.
What's the difference between COPY and ADD?
Both copy files, but ADD has two extra abilities: downloading from remote URLs and auto-extracting local tar archives. Because that behavior can surprise you, always prefer COPY for plain file copies, and use ADD only for special needs like extracting a tarball.
Why is my image still so large?
The most common causes: a non-thin base image (try slim or alpine), not using multi-stage builds, not cleaning up within the same layer, and a missing .dockerignore. Inspect layers with docker history and target the biggest one.
Need an efficient, small and fast image? I can optimize your existing Dockerfiles for layers and cache and speed up your CI/CD pipelines — get in touch.