Featured image of post Reducing Docker Image Sizes with Multi-Stage Builds Featured image of post Reducing Docker Image Sizes with Multi-Stage Builds

Reducing Docker Image Sizes with Multi-Stage Builds

Learn how to separate compile-time tools from production runtimes using Docker multi-stage builds to optimize container footprints and security.

Introduction

Keeping Docker image footprints small is critical for accelerating deployment cycles, lowering storage costs, and tightening security by shrinking the container’s attack surface.

However, a naive Dockerfile construction often bundles compile-time dependencies (like gcc, headers, build caches) and testing tools directly into the final runtime image. This inflates the image size from a few megabytes to hundreds or gigabytes.

To solve this, Docker introduced Multi-Stage Builds. This article reviews the core practices of multi-stage architectures, demonstrating image footprint optimization using a TypeScript Node.js project.


1. What are Multi-Stage Builds?

A multi-stage build allows you to use multiple FROM statements in a single Dockerfile. Each FROM statement initiates a new, isolated “stage” using a fresh base image.

You can selectively copy artifacts (such as compiled binaries or minified packages) from a previous stage to the next. This allows you to discard heavy build dependencies (compilers, source code, linters) and package only the runtime requirements into the final container.

[Build Stage]
・Base: node (large)
・Install devDependencies
・Compile source (npm run build)
          ▼ (Copy only compiled JS & prod dependencies)
[Runtime Stage]
・Base: alpine (minimal)
・Copy compiled output
・CMD Execution

2. Unoptimized vs. Multi-Stage Dockerfiles

Let’s compare a traditional Dockerfile with a multi-stage Dockerfile for a TypeScript application.

The Unoptimized Approach (Heavy Image)

# Uses a full Node image containing compilation environments
FROM node:20

WORKDIR /app

# Restore dependencies
COPY package*.json ./
RUN npm install

# Copy source and compile (TypeScript -> JS)
COPY . .
RUN npm run build

# Start the application
EXPOSE 3000
CMD ["node", "dist/index.js"]
  • The Problem: The resulting image includes the TypeScript compiler (typescript package inside devDependencies), uncompiled .ts source files, caching payloads, and heavy build tools. The final image size often exceeds 900MB.

The Multi-Stage Solution (Slim Image)

# -----------------------------------------------
# Stage 1: Build Environment (builder)
# -----------------------------------------------
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
# Install all dependencies including devDependencies
RUN npm ci

COPY . .
# Transpile TypeScript to JavaScript
RUN npm run build

# Clean up devDependencies to leave only production modules
RUN npm prune --production

# -----------------------------------------------
# Stage 2: Runtime Environment (runner)
# -----------------------------------------------
# Use a minimal, slim OS base (Alpine Linux)
FROM node:20-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production

# Copy only the compiled code and production node_modules from the builder stage
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/index.js"]

3. Why Image Footprints Shrink Dramatically

Implementing this multi-stage setup achieves two main optimizations:

  • Eliminating devDependencies: Running npm prune --production strips away node development packages (such as typescript compilers and linters) leaving only essential production libraries.
  • Adopting Alpine OS: Replacing the Debian-based node image with node:alpine reduces the baseline operating system footprint.
  • The Result: The final container footprint drops from 900MB to under 150MB.

4. Advanced Footprint Reduction Tips

Writing a .dockerignore File

To prevent heavy local directories or sensitive values from entering the build context, create a .dockerignore file in your root folder:

node_modules
.git
.github
npm-debug.log
dist
.env

Layer Cache Tuning

Always place your dependency installation steps (COPY package*.json and RUN npm install) before copying your source code (COPY . .). This ensures Docker caches the dependency layer, skipping package re-installation when only source files change.

Conclusion

Multi-stage builds are a fundamental technique for optimizing container operations:

  1. Name your build stages (e.g., FROM ... AS builder).
  2. Use COPY --from to retrieve only the required files.
  3. Use lightweight base operating systems like Alpine Linux.

Adopt multi-stage Dockerfiles in your project to speed up your CI/CD pipelines and secure your production environments.