Docker Multi-Stage Builds: From 1.2GB Images to 90MB

When I audited our container registry, our main API image was 1.2GB. Every deploy pulled that across the network, every node cached copies of it, and the registry bill grew quietly each month. The image contained compilers, dev dependencies, source maps, git history, none of which the running application needed. Multi-stage builds got it under 100MB. Here's how, and the details that actually matter.

Why images get fat

A naive Dockerfile installs build tools, pulls all dependencies, compiles the app, and ships everything, because each instruction adds a layer and layers are never removed by later instructions. A RUN rm -rf node_modules after the build doesn't shrink the image; the files still exist in the earlier layer. The only way to leave the build environment behind is to not ship it at all.

The multi-stage pattern

Multi-stage builds use multiple FROM statements in one Dockerfile. Each FROM starts a fresh stage; the final stage copies in only the artifacts it needs:

# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# Stage 2: runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Only the final stage becomes the image. The 700MB of compilers and dev dependencies in stage one simply never ship. The same pattern works for PHP (composer install in a build stage, copy vendor/ into a slim FPM image), Go (build in the full SDK image, ship in scratch or distroless), and Python.

Layer ordering is half the win

Multi-stage controls size; layer ordering controls speed. Docker caches each layer and invalidates the cache from the first changed instruction onward. The rule: copy what changes least, first. Copying package.json and installing dependencies before copying source code means a code-only change reuses the cached dependency layer; our CI builds dropped from eight minutes to under two on cache hits. Copying . . before npm ci throws that away on every commit.

Choosing the runtime base image

Whichever you choose, add a .dockerignore (exclude .git, node_modules, logs, local env files) and run as a non-root user in the final stage.

The results

If you do one thing after reading this: open your main Dockerfile, check whether your build tools ship to production, and add a second stage if they do. It's an afternoon of work that pays back on every single deploy.

Frequently asked questions

How do Docker multi-stage builds reduce image size?

You build in one stage with all compilers and dev dependencies, then a second FROM starts a clean stage that copies in only the runtime artifacts. Only the final stage ships, so build tooling never reaches production. Our Node API went from 1.2GB to 140MB and a Go service from 850MB to 18MB.

Why doesn't deleting files in a Dockerfile make the image smaller?

Each Dockerfile instruction creates an immutable layer. A 'RUN rm -rf' in a later instruction hides the files but they still exist in the earlier layer, so the image stays the same size. The only fix is to never ship those layers, which is what multi-stage builds do.

Should I use Alpine, slim, or distroless base images?

Alpine is smallest (~8MB) but its musl libc can break native dependencies; slim Debian-based variants are slightly larger but glibc-compatible and a safe default; distroless has the smallest attack surface (no shell or package manager) but is harder to debug, so it suits mature services.

← All posts