Docker Multi-Stage Build Guide | Optimization, Layer Caching & Security

Docker Multi-Stage Build Guide | Optimization, Layer Caching & Security

이 글의 핵심

Multi-stage builds eliminate build tools from production images — a Node.js app goes from 1.2GB to 85MB. This guide covers stage design, layer caching, .dockerignore, security hardening, and production-ready Dockerfiles.

Why Multi-Stage?

# Single-stage (before)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install          # Installs dev dependencies too
RUN npm run build        # TypeScript → JavaScript
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Image size: ~1.2GB (node image + all node_modules including devDeps)
# Multi-stage (after)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production   # Production deps only
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
# Image size: ~85MB

The build stage is discarded. Only the compiled output and production dependencies end up in the final image.


Layer Caching — The Key to Fast Builds

Docker caches layers. A cached layer is reused if its inputs didn’t change. Order instructions from least-to-most frequently changing:

# ❌ Bad order — source code change invalidates npm install cache
FROM node:20-alpine
WORKDIR /app
COPY . .                 # Changes every commit → cache miss
RUN npm ci               # Re-runs every time = slow CI

# ✅ Good order — npm install only re-runs when package.json changes
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./    # Only changes when adding/removing packages
RUN npm ci               # Cached until package.json changes
COPY . .                 # Source code — cache miss here is fine
RUN npm run build
Typical CI build with good caching:
  Commit 1 (first build):   npm install: 45s, build: 12s = 57s total
  Commit 2 (code change):   npm install: cached, build: 12s = 12s total
  Commit 3 (new package):   npm install: 45s, build: 12s = 57s total

.dockerignore — Exclude Unnecessary Files

.dockerignore prevents files from being sent to Docker during COPY commands:

# .dockerignore
node_modules/           # Never copy — will be installed inside container
dist/                   # Build output — will be regenerated
.git/                   # Git history not needed in image
.env                    # Secrets — never copy
.env.local
coverage/
.nyc_output
*.log
.DS_Store
README.md
*.md
__tests__/
**/*.test.ts
**/*.spec.ts
.github/
.vscode/
docker-compose*.yml
Dockerfile*

Without .dockerignore, COPY . . sends all files including node_modules/ (which can be hundreds of MB) to the Docker daemon before they’re evaluated.


Node.js: Production-Ready Dockerfile

# === Stage 1: Dependencies ===
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files (cached until packages change)
COPY package*.json ./
# npm ci = clean install, exact versions from package-lock.json
RUN npm ci

# === Stage 2: Build ===
FROM node:20-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build      # TypeScript → JavaScript

# Prune dev dependencies
RUN npm prune --production

# === Stage 3: Production ===
FROM node:20-alpine AS runner
WORKDIR /app

# Security: create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs nodeuser

# Copy only what's needed
COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeuser:nodejs /app/package.json ./

# Metadata
LABEL maintainer="[email protected]"
LABEL version="1.0"

# Run as non-root
USER nodeuser

EXPOSE 8000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
  CMD wget -qO- http://localhost:8000/health || exit 1

CMD ["node", "dist/index.js"]

Python: Optimized Dockerfile

# === Stage 1: Build dependencies ===
FROM python:3.12-slim AS builder
WORKDIR /app

# Install build tools (only needed for compiling wheels)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# === Stage 2: Production ===
FROM python:3.12-slim AS runner
WORKDIR /app

# Runtime dependencies only (no gcc, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local

# Copy application
COPY . .

# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

EXPOSE 8000

CMD ["gunicorn", "app.main:app", "--workers", "4", "--bind", "0.0.0.0:8000"]

Go: Tiny Static Binary

Go compiles to a single static binary — the final image can be FROM scratch:

# === Build stage ===
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Download dependencies (cached until go.mod/go.sum change)
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build static binary — no CGO, fully self-contained
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \          # Strip debug info (smaller binary)
    -o /app/server ./cmd/server

# === Final stage: minimal image ===
FROM scratch
# Or use: FROM gcr.io/distroless/static-debian12 (adds CA certs, timezone data)

COPY --from=builder /app/server /server

# Copy TLS certificates for HTTPS outbound requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/server"]
# Final image size: ~10MB (just the binary)

Secrets in Build — Don’t Bake Them In

# ❌ NEVER — secret visible in docker history
RUN echo "machine github.com login user password $GITHUB_TOKEN" > ~/.netrc

# ✅ BuildKit secrets — available during build, not in final image
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    npm install --registry https://npm.pkg.github.com
# Build with secret
docker build \
  --secret id=github_token,env=GITHUB_TOKEN \
  -t my-app .

Multi-Platform Builds

Build for multiple architectures (AMD64 + ARM64 for M-series Macs and AWS Graviton):

# Enable multi-platform builder
docker buildx create --use --name multiplatform

# Build and push for both architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myrepo/my-app:latest \
  --push \
  .
# GitHub Actions: multi-platform CI build
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    platforms: linux/amd64,linux/arm64
    push: true
    tags: myrepo/my-app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

Build Arguments

FROM node:20-alpine AS runner

# ARG for build-time variables
ARG APP_VERSION=unknown
ARG BUILD_DATE

# Convert to ENV to make it available at runtime
ENV APP_VERSION=$APP_VERSION
ENV BUILD_DATE=$BUILD_DATE

# ...
docker build \
  --build-arg APP_VERSION=$(git describe --tags) \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -t my-app .

Image Size Reference

App typeBefore multi-stageAfter multi-stage
Node.js (TypeScript)~1.2 GB~85 MB
Python (FastAPI)~900 MB~150 MB
Go (static binary)~350 MB~10 MB
Java (Spring Boot)~500 MB~150 MB (distroless)

Quick Reference: Security Checklist

# ✅ Use specific version tags (not :latest)
FROM node:20.11-alpine3.19

# ✅ Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# ✅ Read-only filesystem (set at runtime)
# docker run --read-only --tmpfs /tmp my-app

# ✅ No secrets in ENV at build time
# Use runtime env vars or Docker secrets

# ✅ Minimal base image
# Prefer: alpine, slim, distroless, scratch

# ✅ Scan for vulnerabilities
# docker scout cves my-app:latest
# trivy image my-app:latest

Related posts: