Multi-Stage Docker Builds for Minimal Production Images
Multi-stage Docker builds allow web development teams to separate compilation tools and intermediate dependencies from final production runtime environments. This article explains how teams implement multi-stage builds to dramatically reduce container image size, improve deployment speed, and eliminate security vulnerabilities caused by unnecessary development tooling.
The Problem with Single-Stage Builds
In standard single-stage builds, everything needed to build an
application—such as compilers, package managers, development libraries,
and source code—remains inside the final container image. For modern web
applications built with frameworks like React, Next.js, or Go-based
APIs, this results in bloated images that often exceed several
gigabytes. Larger images increase registry storage costs, slow down
CI/CD pipelines, extend deployment times, and expand the attack surface
by leaving build tools like git, npm, or
gcc accessible in production.
How Multi-Stage Builds Work
Multi-stage builds solve this problem by allowing multiple
FROM instructions within a single Dockerfile.
Each FROM instruction begins a new stage with a distinct
base image. Teams can selectively copy build artifacts, compiled
binaries, or optimized assets from one stage to another using the
COPY --from=<stage_name> command, discarding
intermediate layers, caches, and devDependencies along the way.
Implementation Pattern
Web development teams typically structure their Dockerfiles into three primary phases: base dependency management, compilation, and final runtime.
Below is a standard multi-stage implementation for a Node.js web application:
# Stage 1: Dependency resolution and compilation
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies based on lockfile to leverage Docker layer caching
COPY package.json package-lock.json ./
RUN npm ci
# Copy source code and build
COPY . .
RUN npm run build
# Remove development dependencies to keep only production packages
RUN npm prune --production
# Stage 2: Minimal production runtime
FROM node:20-alpine AS runner
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy only production dependencies and built assets from builder stage
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]For compiled languages like Go or Rust, the reduction is even more
dramatic: teams compile the binary in a heavyweight build image and copy
only the single static binary into a scratch or
distroless image, producing an image size of less than 20
megabytes.
For static frontend sites (e.g., Vite, React, Vue), the builder stage
runs npm run build, and the final stage uses an
ultra-lightweight web server like Nginx Alpine, copying only the
generated static HTML, CSS, and JavaScript files to
/usr/share/nginx/html.
Best Practices for Minimal Images
- Use Minimal Base Images: Select minimal OS variants
like
alpineor Google'sdistrolessimages for the final runtime stage to strip out package managers, shells, and unnecessary utilities. - Order Commands for Cache Optimization: Place
instructions that change infrequently (like copying
package.jsonand installing dependencies) before copying the source code, preventing full dependency reinstalls on every code change. - Use
.dockerignore: Exclude localnode_modules, test files,.gitdirectories, and environment files to keep build contexts small and prevent accidental leakage of sensitive files into layers. - Run as Non-Root: Ensure the final stage switches to
a dedicated, unprivileged user (
USER nonrootorappuser) to adhere to container security standards.