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 (
typescriptpackage insidedevDependencies), uncompiled.tssource 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 --productionstrips 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:alpinereduces 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:
- Name your build stages (e.g.,
FROM ... AS builder). - Use
COPY --fromto retrieve only the required files. - 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.
