Skip to content
Zomer Gregorio

Zomer Gregorio

Software Engineer

Resume
Language
Blog

Docker Multi-Stage Builds: How to Optimize Image Size and Security

· Docker · Containers · Security · CI/CD

Learn how to use Docker multi-stage builds to shrink container sizes, strip build-time dependencies, and harden production security postures for enterprise Node.js and Go applications.

Stay updated

Get a short note when I publish something new. Your email or browser subscription is stored only to deliver these updates; unsubscribe anytime. No account or tracking profile is required.

A confirmation email is required before notifications begin.

Introduction to Container Bloat and Security Risks

Containerizing modern applications often introduces a hidden tax: bloated image sizes and inflated attack surfaces. When a single Dockerfile compiles code, installs heavy package managers like npm or apt-get, and bundles source code alongside runtime binaries, the resulting image carries unnecessary baggage. This includes compilers, header files, package caches, and critical Common Vulnerabilities and Exposures (CVEs) residing in build-time tools that have no business running in a production cluster.

Docker multi-stage builds solve this structural inefficiency by letting developers use multiple FROM instructions in a single Dockerfile. Each FROM instruction initiates a new stage of the build with a fresh base image, allowing you to selectively copy artifacts from prior stages while leaving the build toolchain behind. Understanding how to structure these stages correctly is critical for reducing image pull latencies, optimizing container registry storage costs, and improving your overall security posture.

The Mechanics of Multi-Stage Builds

At its core, a multi-stage build relies on the ability to name individual build stages and selectively copy files between them using the --from flag in the COPY instruction. During execution, the Docker daemon evaluates each stage sequentially. Intermediate stages are cached, and unless they are tagged explicitly, they do not persist as final output layers.

Consider a typical TypeScript backend application. The development and compilation phase requires heavy dependencies like typescript and @types/node, whereas the production runtime requires only the compiled JavaScript bundles and production node_modules. A naive Dockerfile builds the app and ships everything. A multi-stage approach splits this cleanly into distinct phases.

Step-by-Step Implementation for Node.js

Here is a production-grade multi-stage Dockerfile designed for a TypeScript Node.js service:

# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src
RUN npm run build
 
# Stage 2: Install production dependencies only
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
 
# Stage 3: Assemble the final production runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
 
# Copy runtime dependencies and compiled output
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
 
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

In this configuration, the builder and dependencies stages contain all tools necessary to fetch modules and compile source code. The final runner stage receives only the compiled output and pruned production modules, dropping thousands of unnecessary files and significantly shrinking the attack surface.

Advanced Optimization Strategies

While basic stage separation drastically cuts image size, achieving maximum optimization requires deliberate attention to layer caching, base image selection, and non-root execution.

Leveraging Build Caches Effectively

Docker evaluates build steps sequentially. If a step changes, all subsequent steps must re-execute. To optimize caching in multi-stage builds, copy dependency manifests (such as package.json or go.mod) before copying source code. This ensures that routine source code modifications do not invalidate expensive package installation layers.

For compiled languages like Go or Rust, multi-stage builds are transformative. Because compiled languages produce a single statically-linked binary, the final runtime stage can use scratch or distroless images rather than a standard Linux distribution.

# Go build stage
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/service
 
# Final scratch stage
FROM scratch
COPY --from=builder /app/bin/service /service
EXPOSE 8080
ENTRYPOINT ["/service"]

Using scratch results in an image containing only your compiled binary, reducing the image size from hundreds of megabytes to mere bytes and completely eliminating shell access for potential attackers.

Minimizing Attack Surfaces with Distroless Images

When applications require a standard C library or basic runtime support that scratch cannot provide, Google's distroless images offer a secure alternative. Distroless images contain only the application and its runtime dependencies, omitting package managers, shells, and debugging utilities.

By copying artifacts from a standard builder image into a distroless runtime image, you mitigate remote code execution risks. Even if an attacker achieves execution within the container, the absence of a shell (like /bin/sh or /bin/bash) prevents them from downloading auxiliary exploit scripts or navigating the filesystem easily.

Common Failure Modes and Edge Cases

Despite their benefits, multi-stage builds introduce subtle pitfalls that can break deployments or degrade build performance if mismanaged.

Mismatched C Architecture and Environment Discrepancies

When building on local developer machines (such as Apple Silicon ARM64) for cloud deployment (typically AMD64), native compilation within a build stage can introduce architecture mismatches if cross-compilation flags are omitted. Ensure that your builder stage matches the target runtime architecture, or leverage Docker Buildx for multi-architecture builds.

Handling Native Node Modules and Build Tools

Certain npm packages require native compilation via node-gyp during installation, which in turn requires python3, make, and g++ in the build environment. If you switch your final runner stage to a minimal alpine or distroless image without ensuring those native dependencies were statically linked or bundled correctly during the build stage, your application will crash on startup with missing shared library errors (e.g., GLIBC mismatches).

To debug these issues, temporarily add a shell back into a debugging tag of your multi-stage build to inspect dynamic linkage using ldd:

FROM runner AS debug
USER root
RUN apk add --no-cache bash

Operational Verification and Security Scanning

Optimizing for size and security must be paired with continuous verification within your CI/CD pipeline. Do not rely solely on visual inspection of image sizes.

  1. Vulnerability Scanning: Run container scanning tools (such as Trivy or Grype) against your final stage artifact to audit remaining packages.
  2. Image Inspector Checks: Use docker image inspect to verify that your final image runs under a non-root user (USER directive) and exposes only necessary ports.
  3. Layer History Audits: Run docker history on the final image to ensure that intermediate build artifacts or source secrets were not accidentally persisted into the production layer via improper COPY commands.

By systematically separating build-time toolchains from runtime environments, engineering teams can maintain fast developer velocity while delivering secure, lightweight containers ready for high-scale orchestration platforms.