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="team@example.com"
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 type | Before multi-stage | After 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: