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

  1. Use Minimal Base Images: Select minimal OS variants like alpine or Google's distroless images for the final runtime stage to strip out package managers, shells, and unnecessary utilities.
  2. Order Commands for Cache Optimization: Place instructions that change infrequently (like copying package.json and installing dependencies) before copying the source code, preventing full dependency reinstalls on every code change.
  3. Use .dockerignore: Exclude local node_modules, test files, .git directories, and environment files to keep build contexts small and prevent accidental leakage of sensitive files into layers.
  4. Run as Non-Root: Ensure the final stage switches to a dedicated, unprivileged user (USER nonroot or appuser) to adhere to container security standards.