Docker Multi-Stage Build Guide | Optimization
이 글의 핵심
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:
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. 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.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- Docker Compose Complete Guide | Multi-Container Apps
- Kubernetes Practical Guide | Pods· Deployments
- GitHub Actions Complete Guide | CI/CD· Workflows
Keywords Covered in This Article (Related Search Terms)
This article covers Docker, DevOps, Containers, Performance, Security, Node.js, CI/CD.