Image Optimization and Multi-Stage Builds

advanced
13 min

Image Optimization and Multi-Stage Builds

Small images pull faster, deploy faster, cost less to store, and expose fewer packages to attackers. Yet naive Dockerfiles routinely produce images ten times larger than necessary. This lesson covers the techniques that shrink images - above all, multi-stage builds.

Why Images Bloat

Three usual suspects:

  1. Heavy base images - node:20 is roughly 400 MB before your code arrives; node:20-alpine is about a tenth of that.
  2. Build tools shipped to production - compilers, dev dependencies, and package manager caches that the running app never uses.
  3. Layer mechanics - deleting a file in a later layer does not remove it from the image; the earlier layer still contains it.

That third point surprises people. This pattern does not save space:

dockerfile
RUN wget https://example.com/big.tar.gz RUN tar xzf big.tar.gz && ./install.sh RUN rm big.tar.gz # too late - it lives in layer 1 forever

Download, use, and delete within a single RUN so the layer never contains the file:

dockerfile
RUN wget https://example.com/big.tar.gz \ && tar xzf big.tar.gz && ./install.sh \ && rm big.tar.gz

Multi-Stage Builds

The biggest wins come from multi-stage builds: use one stage with all the build tooling, then copy only the finished artifacts into a clean final stage. Everything not copied is discarded.

A TypeScript Node API:

dockerfile
# ---- Stage 1: build ---- FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci # includes dev dependencies COPY . . RUN npm run build # emits dist/ # ---- Stage 2: runtime ---- FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/server.js"]

The --from=build flag copies files out of the earlier stage. TypeScript, dev dependencies, and source files never reach the final image.

The effect is even more dramatic for compiled languages. A Go binary needs nothing but itself:

dockerfile
FROM golang:1.22 AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /bin/app FROM scratch COPY --from=build /bin/app /app ENTRYPOINT ["/app"]

scratch is the empty image - the result is often under 15 MB, down from a gigabyte-plus build environment.

Choosing a Base Image

  • alpine variants: very small, using musl libc; occasionally incompatible with native binaries.
  • slim variants: Debian-based, small-ish, maximum compatibility.
  • distroless (from Google): runtime only - no shell, no package manager - excellent security posture.
  • scratch: for static binaries only.

A good default: slim for compatibility, alpine when you have verified your dependencies work on it.

Measuring Your Progress

bash
docker images myapp # overall size docker history myapp:latest # size contributed by each layer

The excellent open source tool dive lets you browse each layer file-by-file and spot waste (dive myapp:latest).

Quick Wins Checklist

  • Switch to a slim or alpine base image.
  • Add a thorough .dockerignore (git history and node_modules are classic stowaways).
  • Combine download-and-cleanup into single RUN instructions.
  • Use npm ci --omit=dev, pip install --no-cache-dir, or apt-get install --no-install-recommends plus rm -rf /var/lib/apt/lists/*.
  • Adopt multi-stage builds so build tooling never ships.

Key Takeaways

  • Deleted files still occupy earlier layers - clean up within the same RUN step.
  • Multi-stage builds separate the build environment from the runtime image.
  • COPY --from=stage moves only the artifacts you need into the final image.
  • Base image choice (alpine, slim, distroless, scratch) sets your size floor.
  • Verify improvements with docker history and the dive tool.

Next up: Docker Best Practices and Security, the final lesson, covering non-root users, image scanning, and production hardening.

Image Optimization and Multi-Stage Builds - Docker | CodeYourCraft | CodeYourCraft