Dockerfile Best Practices (Multi-Stage Builds)
Dockerfile best practices (multi-stage builds)
TL;DR
- Bottom line: Use multi-stage builds to separate build dependencies from runtime, reducing image size by 50-90% and attack surface. Order instructions least-to-most changing, use
.dockerignore, pin versions, run as non-root. - Key tool/command:
FROM node:20-alpine AS build...FROM node:20-alpine...COPY --from=build /app/dist ./dist. - Watch out for: Copying
node_modules/or build tools into the final stage -- only copy compiled output and production dependencies. - Works with: Docker 17.05+ (multi-stage), Docker 23+ (BuildKit default), any language.
Constraints
- NEVER run containers as root in production -- create and switch to a non-root user.
- NEVER use
latesttag for base images -- pin to specific version. - NEVER copy secrets into images -- use
--mount=type=secretor runtime env vars. - Always use
.dockerignoreto exclude.git/,node_modules/, tests. - Order instructions least-to-most changing to maximize layer cache.
- Use
COPYinstead ofADDunless you need tar extraction.
Quick Reference
| Practice | Wrong | Right |
|---|---|---|
| Base image | FROM node:latest | FROM node:20.11-alpine3.19 |
| Non-root user | Running as root | USER app |
| Dependencies first | COPY . . then install | COPY package*.json ./ then install |
| Multi-stage | Single FROM with build tools | Build stage + runtime stage |
| .dockerignore | Not using one | Exclude .git/, node_modules/, tests |
| Secrets | COPY .env . | --mount=type=secret |
| Apt cleanup | No cleanup | rm -rf /var/lib/apt/lists/* |
| COPY vs ADD | ADD . . | COPY . . |
| Health check | No HEALTHCHECK | HEALTHCHECK CMD ... |
| CMD form | CMD node app.js | CMD ["node", "app.js"] |
Decision Tree
START
├── Compiled language (Go, Rust, C++)?
│ ├── YES → Multi-stage: build → copy binary to scratch/distroless
│ └── NO ↓
├── Node.js application?
│ ├── YES → Multi-stage: build (npm ci + build) → runtime (alpine + dist + prod deps)
│ └── NO ↓
├── Python application?
│ ├── YES → Multi-stage: build (pip install) → runtime (slim + site-packages)
│ └── NO ↓
├── Java application?
│ ├── YES → Multi-stage: Maven/Gradle build → copy JAR to JRE-alpine
│ └── NO ↓
├── Static site (React, Vue)?
│ ├── YES → Multi-stage: node build → nginx:alpine serving dist/
│ └── NO ↓
└── DEFAULT → Multi-stage with appropriate builder and slim runtime
Step-by-Step Guide
1. Create .dockerignore
Exclude unnecessary files from build context. [src1]
# .dockerignore
node_modules/
.git/
*.md
.env
.env.*
coverage/
tests/
.github/
.vscode/
*.log
Verify: docker build . → context size should be small.
2. Pin base image versions
Use specific versions for reproducible builds. [src1]
FROM node:20.11-alpine3.19
Verify: Same digest every pull.
3. Order instructions for caching
Copy deps first, install, then copy source. [src1]
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
Verify: Change source → rebuild → npm ci shows "CACHED".
4. Implement multi-stage build
Separate build from runtime. [src2]
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
Verify: docker images → final image is significantly smaller.
5. Add non-root user
Run as unprivileged user. [src1]
RUN addgroup -S app && adduser -S app -G app
COPY --chown=app:app . .
USER app
Verify: docker exec <container> whoami → app.
6. Add health check
Monitor container health. [src7]
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Verify: docker inspect <container> → shows health status.
Code Examples
Node.js production Dockerfile (multi-stage)
# Input: Node.js app with package.json and src/
# Output: Optimized production container (~150MB)
FROM node:20.11-alpine3.19 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
RUN npm prune --production
FROM node:20.11-alpine3.19
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/package.json ./
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
Go static binary (scratch image)
# Input: Go app with go.mod
# Output: Minimal container (~15MB)
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Python Flask/Django (multi-stage)
# Input: Python app with requirements.txt
# Output: Slim production container (~120MB)
FROM python:3.12-slim AS build
WORKDIR /app
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 --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN groupadd -r app && useradd -r -g app app
COPY --from=build /install /usr/local
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=app:app . .
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:create_app()"]
Static site with Nginx
# Input: React/Vue/Angular project
# Output: Nginx serving static files (~25MB)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
Anti-Patterns
Wrong: Single-stage with dev dependencies
# ❌ BAD — 1GB+ image with build tools and devDependencies
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
Correct: Multi-stage with production only
# ✅ GOOD — ~150MB with only production artifacts
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
Wrong: COPY before deps install
# ❌ BAD — any source change invalidates npm ci cache
COPY . .
RUN npm ci
Correct: Copy package files first
# ✅ GOOD — npm ci cached unless package.json changes
COPY package*.json ./
RUN npm ci
COPY . .
Wrong: Running as root
# ❌ BAD — security risk
FROM node:20-alpine
COPY . .
CMD ["node", "index.js"]
Correct: Non-root user
# ✅ GOOD — unprivileged user
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
COPY --chown=app:app . .
USER app
CMD ["node", "index.js"]
Common Pitfalls
- Image too large: Using full base (node:20 is ~1GB). Fix: use Alpine or distroless. [src1]
- Cache busted by source changes:
COPY . .before install. Fix: copy package files first. [src1] - Secrets in layers:
COPY .env .persists in layers. Fix: use--mount=type=secret. [src3] - apt lists not cleaned: Creates large layers. Fix:
&& rm -rf /var/lib/apt/lists/*in same RUN. [src1] - Shell form CMD:
CMD node app.jswraps in sh -c. Fix:CMD ["node", "app.js"]. [src7] - Missing .dockerignore: .git/ and node_modules/ in context. Fix: create .dockerignore. [src1]
Diagnostic Commands
# Check image size
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# Analyze layers
docker history <image>:<tag>
# Deep dive with dive tool
dive <image>:<tag>
# Check if running as root
docker exec <container> whoami
# Health check status
docker inspect <container> --format '{{json .State.Health}}'
# Scan vulnerabilities
docker scout cve <image>:<tag>
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Docker 26.x | Current | BuildKit required for --mount | Ensure BuildKit enabled |
| Docker 25.x | Active | Improved compression | — |
| Docker 24.x | Active | BuildKit is default | Remove DOCKER_BUILDKIT=1 |
| Multi-stage | Since 17.05 | — | Supported on all modern Docker |
| BuildKit | Since 18.09 | — | Default since 24.x |
| Distroless | Stable | No shell | Use debug variant for debugging |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building production images | Dev-only containers | docker-compose with volumes |
| Need smallest image size | Need debug tools in image | Debug base image for dev |
| Deploying to K8s or cloud | One-off scripts | Direct execution or serverless |
| Security-sensitive deployment | Prototype or hackathon | Simple single-stage Dockerfile |
Important Caveats
- Alpine uses musl libc. Some native modules (bcrypt, sharp) may need pre-built binaries. Test thoroughly.
scratchimages have no shell, no package manager. Cannotdocker execinto them.- Multi-stage builds increase complexity. Simple apps may be fine with single-stage.
- Docker layer caching invalidates when any instruction changes. All subsequent layers rebuild.
npm prune --productionmay leave npm cache. Usenpm cache clean --forceafter.