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
- Alpine is the popular small option (~8MB), but its musl libc occasionally breaks native dependencies. Test before committing to it.
- Slim variants (e.g.
debian-slimbased images) are slightly larger but glibc-compatible, my default when Alpine misbehaves. - Distroless images contain no shell or package manager at all: smallest attack surface, but harder to debug inside. Good for mature services.
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
- Node API: 1.2GB → 140MB. Go service: 850MB → 18MB. PHP-FPM app: 980MB → 240MB.
- Deploy pulls went from ~90 seconds to under 15.
- Vulnerability scans got dramatically quieter: most CVEs lived in build tooling we no longer ship.
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.