---
# === IDENTITY ===
id: software/devops/dockerfile-best-practices/2026
canonical_question: "Dockerfile best practices (multi-stage builds)"
aliases:
  - "Dockerfile multi-stage build optimization"
  - "Docker image size reduction best practices"
  - "Dockerfile layer caching optimization"
  - "Production Dockerfile patterns"
entity_type: software_reference
domain: software > devops > Dockerfile Best Practices
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === VERIFICATION ===
last_verified: 2026-02-28
confidence: 0.94
version: 1.0
first_published: 2026-02-28

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: "Docker 25.x introduced BuildKit compression improvements (Jan 2024); Docker 24.x made BuildKit the default builder (2023)"
  next_review: 2026-08-27
  change_sensitivity: low

# === CONSTRAINTS ===
constraints:
  - "NEVER run containers as root in production -- always create and switch to a non-root user with USER directive"
  - "NEVER use latest tag for base images in production -- always pin to specific version (e.g., node:20.11-alpine3.19)"
  - "NEVER copy secrets (API keys, passwords, certificates) into Docker images -- use build secrets (--mount=type=secret) or runtime env vars"
  - "Always use .dockerignore to exclude node_modules/, .git/, test files, and documentation from the build context"
  - "Order Dockerfile instructions from least to most frequently changing to maximize layer cache reuse"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Need Docker Compose multi-service setup"
    use_instead: "software/devops/docker-compose-reference/2026"
  - condition: "Need GitHub Actions Docker build workflow"
    use_instead: "software/devops/github-actions-docker/2026"
  - condition: "Docker container won't start (debugging)"
    use_instead: "software/debugging/docker-container-wont-start/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: "language"
    question: "What language/runtime does your application use?"
    type: choice
    options: ["Node.js", "Python", "Go", "Java/Kotlin", "Rust", ".NET", "Ruby"]
  - key: "base_image"
    question: "What base image preference do you have?"
    type: choice
    options: ["Alpine (smallest)", "Debian slim", "Ubuntu", "Distroless (most secure)", "scratch (Go/Rust static binaries)"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/devops/dockerfile-best-practices/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-02-28)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "software/devops/github-actions-docker/2026"
      label: "GitHub Actions Docker Build and Push"
    - id: "software/debugging/docker-container-wont-start/2026"
      label: "Docker Container Won't Start"
  solves:
    - id: "software/debugging/docker-oomkilled/2026"
      label: "Docker OOMKilled Debugging"
  alternative_to: []
  often_confused_with:
    - id: "software/devops/github-actions-docker/2026"
      label: "GitHub Actions Docker Build and Push"

# === SOURCES ===
sources:
  - id: src1
    title: "Best practices"
    author: Docker
    url: https://docs.docker.com/build/building/best-practices/
    type: official_docs
    published: 2024-06-01
    reliability: authoritative
  - id: src2
    title: "Multi-stage builds"
    author: Docker
    url: https://docs.docker.com/build/building/multi-stage/
    type: official_docs
    published: 2024-06-01
    reliability: authoritative
  - id: src3
    title: "Best Practices for Building Docker Images"
    author: Better Stack
    url: https://betterstack.com/community/guides/scaling-docker/docker-build-best-practices/
    type: technical_blog
    published: 2024-09-15
    reliability: high
  - id: src4
    title: "Dockerfile Performance Optimization: Best Practices Explained"
    author: Vasanthan K
    url: https://medium.com/@vasanthancomrads/dockerfile-performance-optimization-best-practices-explained-25d85877f12b
    type: technical_blog
    published: 2026-01-15
    reliability: moderate_high
  - id: src5
    title: "How to Build Smaller Container Images: Docker Multi-Stage Builds"
    author: iximiuz Labs
    url: https://labs.iximiuz.com/tutorials/docker-multi-stage-builds
    type: technical_blog
    published: 2024-03-10
    reliability: high
  - id: src6
    title: "Docker Multi-stage Builds: Smaller Images, Faster Deployments"
    author: CleanStart
    url: https://www.cleanstart.com/guide/multi-stage-build
    type: technical_blog
    published: 2024-08-20
    reliability: moderate_high
  - id: src7
    title: "Dockerfile reference"
    author: Docker
    url: https://docs.docker.com/reference/dockerfile/
    type: official_docs
    published: 2024-06-01
    reliability: authoritative
---

# Dockerfile Best Practices (Multi-Stage Builds)

## TL;DR

- **Bottom line**: Use multi-stage builds to separate build dependencies from runtime, reducing final image size by 50-90% and attack surface. Order instructions from least to most changing, use `.dockerignore`, pin base image versions, and run as non-root.
- **Key tool/command**: `FROM node:20-alpine AS build` ... `FROM node:20-alpine` ... `COPY --from=build /app/dist ./dist` for multi-stage patterns.
- **Watch out for**: Copying `node_modules/` or build tools into the final stage -- only copy the compiled output and production dependencies.
- **Works with**: Docker 17.05+ (multi-stage), Docker 23+ (BuildKit default), any language/framework.

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- NEVER run containers as root in production -- create a non-root user with `RUN addgroup -S app && adduser -S app -G app` and `USER app`.
- NEVER use `latest` tag for base images -- pin to specific version (e.g., `node:20.11.1-alpine3.19`).
- NEVER copy secrets into images -- use `--mount=type=secret` for build-time secrets or environment variables at runtime.
- Always use `.dockerignore` to exclude `.git/`, `node_modules/`, test files, and docs from build context.
- Order instructions least-to-most-changing: OS packages -> dependency files -> install dependencies -> copy source code -> build.
- Use `COPY` instead of `ADD` unless you specifically need URL downloading or 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` after creating user |
| Dependencies first | `COPY . .` then `npm install` | `COPY package*.json ./` then `npm ci` then `COPY . .` |
| Multi-stage | Single `FROM` with build tools | `FROM ... AS build` + `FROM ... AS runtime` |
| .dockerignore | Not using one | Exclude `.git/`, `node_modules/`, `*.md`, tests |
| Secrets | `COPY .env .` or `ENV SECRET=value` | `--mount=type=secret` or runtime env |
| Package install | `apt-get install` without cleanup | `apt-get install && rm -rf /var/lib/apt/lists/*` |
| COPY vs ADD | `ADD . .` | `COPY . .` (ADD only for tar/URL) |
| Health check | No HEALTHCHECK | `HEALTHCHECK CMD curl -f http://localhost:3000/health` |
| Signal handling | `CMD node app.js` (shell form) | `CMD ["node", "app.js"]` (exec form) |
| Layer count | Many small RUN commands | Combine related commands with `&&` |
| Apt lists | Not cleaning up | `rm -rf /var/lib/apt/lists/*` in same RUN |

## Decision Tree

```
START
├── Compiled language (Go, Rust, C++)?
│   ├── YES → Multi-stage: build stage → copy binary to scratch/distroless
│   └── NO ↓
├── Node.js application?
│   ├── YES → Multi-stage: build stage (npm ci + build) → runtime stage (node:alpine + dist + prod deps)
│   └── NO ↓
├── Python application?
│   ├── YES → Multi-stage: build stage (pip install) → runtime stage (python:slim + site-packages)
│   └── NO ↓
├── Java application?
│   ├── YES → Multi-stage: build with Maven/Gradle → copy JAR to eclipse-temurin:*-jre-alpine
│   └── NO ↓
├── Static site (React, Vue, Angular)?
│   ├── YES → Multi-stage: node build → nginx:alpine serving dist/
│   └── NO ↓
└── DEFAULT → Multi-stage with language-appropriate builder and slim runtime
```

## Step-by-Step Guide

### 1. Create a .dockerignore file

Exclude unnecessary files from the build context to speed up builds and prevent secrets from leaking into images. [src1]

```
# .dockerignore
node_modules/
.git/
.gitignore
*.md
README*
LICENSE
.env
.env.*
docker-compose*.yml
Dockerfile
.dockerignore
coverage/
tests/
test/
__tests__/
.github/
.vscode/
*.log
```

**Verify**: `docker build .` → context size should be much smaller (check first line of build output).

### 2. Pin base image versions

Never use `latest` -- pin to specific version for reproducible builds. [src1]

```dockerfile
# ❌ FROM node:latest
# ❌ FROM node:20
# ✅ Pin to specific minor or patch version
FROM node:20.11-alpine3.19
```

**Verify**: `docker pull node:20.11-alpine3.19` → same digest every time.

### 3. Order instructions for optimal caching

Copy dependency files first, install dependencies, then copy source code. This way, changing source code does not invalidate the dependency cache. [src1]

```dockerfile
FROM node:20.11-alpine3.19

WORKDIR /app

# Step 1: Copy only dependency files (changes rarely)
COPY package.json package-lock.json ./

# Step 2: Install dependencies (cached unless package files change)
RUN npm ci --only=production

# Step 3: Copy source code (changes frequently)
COPY . .

# Step 4: Build (re-runs when source changes)
RUN npm run build
```

**Verify**: Change source code → rebuild → "CACHED" appears for npm ci step.

### 4. Implement multi-stage build

Separate build dependencies from the runtime image. [src2]

```dockerfile
# Stage 1: Build
FROM node:20.11-alpine3.19 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20.11-alpine3.19
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=build /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/index.js"]
```

**Verify**: `docker images` → final image is significantly smaller than a single-stage build.

### 5. Add non-root user

Create a dedicated user and switch to it before running the application. [src1]

```dockerfile
FROM node:20.11-alpine3.19
WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY package.json package-lock.json ./
RUN npm ci --only=production

COPY --chown=appuser:appgroup . .

USER appuser
EXPOSE 3000
CMD ["node", "index.js"]
```

**Verify**: `docker exec <container> whoami` → `appuser` (not `root`).

### 6. Add health check

Enable Docker to automatically monitor container health. [src7]

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
```

**Verify**: `docker inspect <container> | jq '.[0].State.Health'` → shows health status.

## Code Examples

### Node.js production Dockerfile (multi-stage)

> Full script: [node-js-production-dockerfile-multi-stage.Dockerfile](scripts/node-js-production-dockerfile-multi-stage.Dockerfile) (29 lines)

```dockerfile
# Dockerfile
# Input:  Node.js application with package.json and src/
# Output: Optimized production container (~150MB vs ~1GB)
# === Build Stage ===
FROM node:20.11-alpine3.19 AS build
# ... (see full script)
```

### Go static binary (scratch image)

```dockerfile
# Dockerfile
# Input:  Go application with go.mod
# Output: Minimal container (~15MB, no OS, no shell)

# === Build Stage ===
FROM golang:1.22-alpine AS build
WORKDIR /app

# Cache dependency downloads
COPY go.mod go.sum ./
RUN go mod download

# Build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server

# === Production Stage ===
FROM scratch

# Copy CA certificates for HTTPS
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy binary
COPY --from=build /server /server

EXPOSE 8080
ENTRYPOINT ["/server"]
```

### Python Flask/Django (multi-stage)

> Full script: [python-flask-django-multi-stage.Dockerfile](scripts/python-flask-django-multi-stage.Dockerfile) (27 lines)

```dockerfile
# Dockerfile
# Input:  Python application with requirements.txt
# Output: Slim production container (~120MB)
# === Build Stage ===
FROM python:3.12-slim AS build
# ... (see full script)
```

### Static site with Nginx

```dockerfile
# Dockerfile
# Input:  React/Vue/Angular project
# Output: Nginx serving static files (~25MB)

FROM node:20.11-alpine3.19 AS build
WORKDIR /app
COPY package.json package-lock.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 build with dev dependencies

```dockerfile
# ❌ BAD — final image contains build tools, test frameworks, devDependencies (1GB+)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
```

### Correct: Multi-stage build with production only

```dockerfile
# ✅ GOOD — final image has only production deps and compiled output (~150MB)
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 dependency install

```dockerfile
# ❌ BAD — any source change invalidates the npm ci cache layer
FROM node:20-alpine
COPY . .
RUN npm ci
```

### Correct: Copy package files first, then source

```dockerfile
# ✅ GOOD — npm ci is cached unless package.json/lock changes
FROM node:20-alpine
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
```

### Wrong: Running as root

```dockerfile
# ❌ BAD — container runs as root, security risk
FROM node:20-alpine
COPY . .
CMD ["node", "index.js"]
```

### Correct: Dedicated non-root user

```dockerfile
# ✅ GOOD — container runs as 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 OS base images (e.g., `node:20` is ~1GB). Fix: use Alpine variants (`node:20-alpine` is ~180MB) or distroless. [src1]
- **Cache busted by source changes**: `COPY . .` before `npm ci` invalidates cache on every code change. Fix: copy `package*.json` first, install, then copy source. [src1]
- **Secrets baked into layers**: `COPY .env .` or `ENV SECRET=value` persists in image layers. Fix: use `--mount=type=secret` or runtime environment variables. [src3]
- **apt-get lists not cleaned**: `apt-get update` creates lists in `/var/lib/apt/lists/`. Fix: combine install and cleanup in one RUN: `&& rm -rf /var/lib/apt/lists/*`. [src1]
- **Shell form CMD ignores signals**: `CMD node app.js` wraps in `/bin/sh -c`, preventing SIGTERM delivery. Fix: use exec form `CMD ["node", "app.js"]`. [src7]
- **Missing .dockerignore**: Entire `.git/` and `node_modules/` sent as context. Fix: create `.dockerignore` matching `.gitignore` plus Docker-specific excludes. [src1]

## Diagnostic Commands

```bash
# Check image size
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

# Analyze image layers and size breakdown
docker history <image>:<tag>

# Deep dive with dive tool
dive <image>:<tag>

# Check if running as root
docker exec <container> whoami

# Inspect health check status
docker inspect <container> --format '{{json .State.Health}}'

# View build context size (first line of build output)
docker build --no-cache .

# Scan for vulnerabilities
docker scout cve <image>:<tag>

# Check .dockerignore is working
docker build --no-cache . 2>&1 | head -1
# Should show small context size
```

## Version History & Compatibility

| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Docker 26.x | Current | BuildKit required for --mount | Ensure BuildKit is enabled |
| Docker 25.x | Active | Improved compression features | — |
| Docker 24.x | Active | BuildKit is default builder | Remove DOCKER_BUILDKIT=1 env var |
| Docker 23.x | Maintenance | — | — |
| Multi-stage builds | Since 17.05 | — | Supported on all modern Docker |
| BuildKit | Since 18.09 | — | Default since 24.x, explicit before |
| Distroless images | Stable | No shell, no package manager | Debug with debug variant |

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building production container images | Development-only containers | docker-compose with volume mounts |
| Need smallest possible image size | Need debugging tools in image | Use debug base image in dev |
| Deploying to Kubernetes or cloud | One-off scripts or batch jobs | Direct execution or serverless |
| CI/CD pipeline container builds | Need Windows containers | Windows-specific Dockerfile patterns |
| Security-sensitive deployments | Prototype or hackathon | Simple single-stage Dockerfile |

## Important Caveats

- Alpine images use musl libc instead of glibc. Some Node.js native modules (e.g., `bcrypt`, `sharp`) may need `--platform=linux/amd64` or pre-built binaries. Test thoroughly.
- `scratch` images have no shell, no package manager, and no debugging tools. You cannot `docker exec` into them. Use a debug sidecar or `gcr.io/distroless/...:debug` variant.
- Multi-stage builds increase build complexity. For simple applications, a well-optimized single-stage Dockerfile may be sufficient.
- Docker layer caching is invalidated when any instruction changes. A single changed `COPY` line invalidates all subsequent layers.
- `npm prune --production` in the build stage removes devDependencies but may leave behind files in the npm cache. Use `npm cache clean --force` after pruning.

## Related Units

- [GitHub Actions: Docker Build and Push](/software/devops/github-actions-docker/2026)
- [Docker Container Won't Start](/software/debugging/docker-container-wont-start/2026)
- [Docker OOMKilled Debugging](/software/debugging/docker-oomkilled/2026)
