Dockerfile Best Practices (Multi-Stage Builds)

Type: Software Reference Confidence: 0.94 Sources: 7 Verified: 2026-02-28 Freshness: 2026-02-28

TL;DR

Constraints

Quick Reference

PracticeWrongRight
Base imageFROM node:latestFROM node:20.11-alpine3.19
Non-root userRunning as rootUSER app
Dependencies firstCOPY . . then installCOPY package*.json ./ then install
Multi-stageSingle FROM with build toolsBuild stage + runtime stage
.dockerignoreNot using oneExclude .git/, node_modules/, tests
SecretsCOPY .env .--mount=type=secret
Apt cleanupNo cleanuprm -rf /var/lib/apt/lists/*
COPY vs ADDADD . .COPY . .
Health checkNo HEALTHCHECKHEALTHCHECK CMD ...
CMD formCMD node app.jsCMD ["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> whoamiapp.

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

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

VersionStatusBreaking ChangesMigration Notes
Docker 26.xCurrentBuildKit required for --mountEnsure BuildKit enabled
Docker 25.xActiveImproved compression
Docker 24.xActiveBuildKit is defaultRemove DOCKER_BUILDKIT=1
Multi-stageSince 17.05Supported on all modern Docker
BuildKitSince 18.09Default since 24.x
DistrolessStableNo shellUse debug variant for debugging

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building production imagesDev-only containersdocker-compose with volumes
Need smallest image sizeNeed debug tools in imageDebug base image for dev
Deploying to K8s or cloudOne-off scriptsDirect execution or serverless
Security-sensitive deploymentPrototype or hackathonSimple single-stage Dockerfile

Important Caveats

Related Units