Docker Multi-Stage Builds â Optimizing Image Size, Build Patterns, and Security Best Practices
In this tutorial, you'll learn about Docker Multi. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Docker multi-stage builds use multiple FROM statements in a single Dockerfile to separate the build environment from the runtime environment, producing smaller, more secure final images.
What You'll Learn
Why It Matters
A typical Node.js application Docker image built without multi-stage techniques includes the entire build toolchain â TypeScript compiler, npm, dev dependencies, source maps, and test files. These images can be 1.5 GB or more. Multi-stage builds strip away everything not needed at runtime, producing images as small as 120 MB. Smaller images mean faster deployments, less disk usage, fewer vulnerabilities, and a reduced attack surface.
Real-World Use
DodaZIP uses multi-stage Dockerfiles for all services â the Go backend builds in a golang:1.22 image with full SDK, then copies only the compiled binary into a scratch image. The resulting image is under 10 MB and contains nothing but the binary, making it trivially auditable and nearly impossible to exploit.
flowchart LR
A[Source Code] --> B[Build Stage]
B --> C[NPM Install]
C --> D[TypeScript Compile]
D --> E[Test Run]
E --> F[Runtime Stage]
F --> G[Minimal Base Image]
G --> H[Copy Artifacts Only]
H --> I[Final Image: ~150MB]
style F fill:#2496ed,color:#fff
style I fill:#4CAF50,color:#fff
Prerequisites: Basic Docker knowledge â writing Dockerfiles, building images, and running containers. Familiarity with Containerization concepts.
Basic Multi-Stage Pattern
The fundamental pattern uses one stage for building and a second stage for the runtime image.
# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
# Copy dependency manifests first for layer caching
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine AS runner
WORKDIR /app
# Copy only the built artifacts from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules --chown=node:node ./node_modules
COPY --from=builder /app/package.json ./
# Run as non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Expected output: The first stage installs all dependencies and compiles TypeScript. The second stage starts from a fresh Alpine image and copies only the compiled output and production dependencies. The final image is significantly smaller than a single-stage build.
# Build the image
docker build -t myapp:multistage .
# Expected output:
# [+] Building 45.2s (12/12) FINISHED
# => [builder 1/5] FROM node:20-slim
# => [builder 2/5] COPY package.json package-lock.json ./
# => [builder 3/5] RUN npm ci
# => [builder 4/5] COPY . .
# => [builder 5/5] RUN npm run build
# => [runner 1/4] FROM node:20-alpine
# => [runner 2/4] COPY --from=builder /app/dist ./dist
# => [runner 3/4] COPY --from=builder /app/node_modules ./node_modules
# Compare sizes
docker images myapp
# Expected output:
# REPOSITORY TAG SIZE
# myapp multistage 148MB
# myapp single-stage 1.2GB
Expected output: The multi-stage image is 148 MB compared to 1.2 GB for the single-stage build â an 88% reduction.
Language-Specific Patterns
Go â Zero Dependency Runtime
Go compiles to a static binary with no runtime dependencies, making it ideal for scratch images.
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Leverage Docker cache for dependencies
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build a statically-linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: Scratch (nothing at all)
FROM scratch AS runner
# Security: copy CA certs for SSL/TLS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Expected output: The scratch image starts with zero files. Only the compiled binary and SSL certificates are added. The final image is typically 5-15 MB with zero OS packages to patch.
Python â Virtual Environments
Python requires the runtime Interpreter but the build-time compilers and headers can be excluded.
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM python:3.12-slim AS runner
WORKDIR /app
# Copy only installed packages from builder
COPY --from=builder /root/.local /root/.local
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
COPY app/ ./app/
USER nobody
CMD ["python", "app/main.py"]
Expected output: The Builder stage installs build-essential and libpq-dev for compiling native extensions. The runtime image has only the installed Python packages and application code â no compilers, headers, or build tools.
Advanced Optimization Techniques
Dependency Caching
Leveraging Docker's layer cache prevents re-installing dependencies when only source code changes.
FROM node:20-alpine AS builder
WORKDIR /app
# Step 1: Copy only dependency files
COPY package.json package-lock.json ./
# Step 2: Install dependencies (cached unless package files change)
RUN npm ci
# Step 3: Copy source code (invalidates cache from here)
COPY . .
# Step 4: Build
RUN npm run build
Expected behavior: If only source code changes (not package.json), Docker reuses the cached npm ci layer. Build time drops from 60 seconds to 15 seconds because npm install is skipped.
BuildKit Cache Mounts
BuildKit supports cache mounts that persist between builds without being included in the final image.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Use cache mount for Go build cache
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
Expected behavior: The Go build cache persists across builds. A clean build takes 120 seconds; subsequent builds take 15 seconds because cached object files are reused.
Distroless Base Images
Google's distroless images contain only the runtime essentials â no shell, no package manager, no utilities.
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/index.js"]
Expected output: Distroless images strip everything except the runtime and the application. There is no shell, no curl, no ls â significantly reducing the attack surface. If an attacker gains code execution, they cannot run exploit tools or download malware.
Security Patterns
Running as Non-Root
Always create and switch to a non-root user in the runtime stage.
FROM node:20-alpine AS runner
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Expected behavior: The container runs as appuser instead of root. If the application is compromised, the attacker has restricted permissions â they cannot install packages, modify system files, or access other processes.
Minimal Base Image Selection
# FROM node:20 (1.1 GB â includes build tools, Yarn, Python, Git)
# FROM node:20-slim (210 MB â minimal OS + Node)
# FROM node:20-alpine (120 MB â musl-based, very small)
# FROM gcr.io/distroless/nodejs20-debian12 (150 MB â no shell, no package manager)
Expected behavior: Each progressively smaller base image reduces the attack surface. Alpine-based images are popular but use musl libc instead of glibc, which can cause compatibility issues with native modules. Distroless images offer the best security posture.
Common Errors
Copying
node_modulesfrom Builder instead of rebuilding:COPY --from=<a href="/design-patterns/builder/">Builder</a> /app/node_modulescopies dev dependencies too. Runnpm ci --productionin a separate step or usenpm prune --productionbefore copying.Not pinning base image versions:
FROM node:20changes over time as new patches are released. Pin to a specific digest:FROM node:20.11.0-slim@sha256:xxxxx.Including secrets in build stages: Build arguments (
--build-arg) passed to the Builder stage are visible in the image history. Use BuildKit's--secretflag for sensitive data.Skipping
.dockerignore: Without.dockerignore, Docker sends the entire project directory (includingnode_modules,.git,coverage) to the Docker daemon as build context, slowing builds and potentially including secrets.Running as root in the runtime container: Root in the container is root on the host if container isolation is compromised. Always create and use a non-root user.
Not cleaning up package manager caches:
apt-get installleaves cached.debfiles. Add&& rm -rf /var/lib/apt/lists/*to the same RUN instruction.
Practice Questions
Why are multi-stage builds smaller than single-stage builds? Answer: Multi-stage builds separate the build environment (compilers, dev dependencies, source code) from the runtime environment. Only the compiled artifacts and production dependencies are copied to the final image.
How does Docker layer Caching work with multi-stage builds? Answer: Each instruction creates a layer. Docker caches layers and reuses them if the instruction and context haven't changed. Copying dependency manifests before source code maximizes cache hits.
Why is a scratch image the most secure option for Go binaries? Answer: Scratch images contain zero files â no shell, no utilities, no package manager. An attacker with code execution cannot run any commands or download tools because there is nothing to execute.
What is the advantage of BuildKit cache mounts over traditional layer Caching? Answer: Cache mounts persist build caches between builds without adding them to image layers. The cache speeds up builds but is not included in the final image.
Challenge
Create a Dockerfile for a Go web application that builds with CGO_ENABLED=0, uses a scratch runtime image, copies only the compiled binary and CA certificates, runs as a non-root user, and utilizes BuildKit cache mounts for the Go module and build caches. Compare the image size with a single-stage golang:1.22 build.
Mini Project
Take an existing Node.js or Python web application and optimize its Dockerfile using multi-stage builds. Start by measuring the single-stage image size with docker images. Refactor to a multi-stage Dockerfile that: separates build and runtime stages, uses a minimal base image (Alpine or distroless), copies only production dependencies, runs as a non-root user, uses .dockerignore to exclude unnecessary files, and integrates BuildKit cache mounts. Measure the final image size and build time improvements. Document the security improvements of the smaller attack surface for a report to your team.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro