Skip to content

Docker Multi-Stage Builds — Optimizing Image Size, Build Patterns, and Security Best Practices

DodaTech Updated 2026-06-22 8 min read

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
â„šī¸ Info

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

  1. Copying node_modules from Builder instead of rebuilding: COPY --from=<a href="/design-patterns/builder/">Builder</a> /app/node_modules copies dev dependencies too. Run npm ci --production in a separate step or use npm prune --production before copying.

  2. Not pinning base image versions: FROM node:20 changes over time as new patches are released. Pin to a specific digest: FROM node:20.11.0-slim@sha256:xxxxx.

  3. Including secrets in build stages: Build arguments (--build-arg) passed to the Builder stage are visible in the image history. Use BuildKit's --secret flag for sensitive data.

  4. Skipping .dockerignore: Without .dockerignore, Docker sends the entire project directory (including node_modules, .git, coverage) to the Docker daemon as build context, slowing builds and potentially including secrets.

  5. 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.

  6. Not cleaning up package manager caches: apt-get install leaves cached .deb files. Add && rm -rf /var/lib/apt/lists/* to the same RUN instruction.

Practice Questions

  1. 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.

  2. 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.

  3. 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.

  4. 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