Dockerfile Development Best Practices
Base Images
- Pin to specific version:
FROM node:22.14-alpine3.21. Neverlatestor untagged. - Pin by digest for reproducibility:
FROM node:22.14-alpine3.21@sha256:abc123.... - Prefer minimal bases: distroless > alpine > slim > full.
distroless: no shell, no package manager — smallest attack surface, production only.alpine: ~5MB, has shell and apk — good default for most workloads.slim: ~70MB Debian with minimal packages — when you need glibc.
- Cross-architecture:
FROM --platform=linux/amd64 image:tag.
Layer Ordering & Caching
Order instructions from least-changed to most-changed:
# 1. System deps (rarely change)
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# 2. App dependency files (change occasionally)
COPY package.json package-lock.json ./
RUN npm ci
# 3. Source code (changes frequently)
COPY src/ src/
- Each
RUN,COPY,ADDcreates a layer.ENV,LABEL,EXPOSE,WORKDIRare metadata-only. - Combine install + cleanup in the same
RUN— separate layers retain deleted files. - Use
COPY --linkfor independent layers that build in parallel.
Multi-stage Builds
Always multi-stage. Name every stage — never reference by index.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
- Build stage: compilers, dev deps. Runtime stage: only artifacts.
COPY --from=builder /path /pathto copy between stages.- Separate test target for CI:
docker build --target test .. - Full language-specific templates: see patterns/multi-stage-templates.md.
Instructions
- COPY over ADD always.
ADDonly for local tar auto-extraction. For URLs, usecurl/wgetinRUN. - Exec form for
CMDandENTRYPOINT:CMD ["node", "server.js"] # correct: receives signals # CMD node server.js # wrong: wraps in /bin/sh -c, breaks signals - ENTRYPOINT + CMD combo: ENTRYPOINT for the binary, CMD for default args (overridable at
docker run):ENTRYPOINT ["python", "-m", "myapp"] CMD ["--port=8080"] - ARG vs ENV: ARG is build-time only (and visible in
docker history). ENV persists in the image. ARG before FROM is scoped to the FROM line only. - WORKDIR /app over
RUN mkdir -p /app && cd /app. Creates the dir and sets it for all subsequent instructions. - EXPOSE is documentation only — does not publish ports.
Security
- Run as non-root. Add
USERafter allRUNinstructions that need root:RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser:appgroup - Never bake secrets into images. No
ARG SECRET, noCOPY .env, noENV API_KEY=.... Use BuildKit--mount=type=secret(see patterns/security-hardening.md). COPY --chown=appuser:appgroupto set ownership without extra layers.- Clean package caches in the same
RUN:RUN apt-get update && apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/*
.dockerignore
Always create one. Without it, the entire directory (.git, node_modules, local env) goes into the build context.
.git
.github
.vscode
.idea
*.md
docker-compose*.yml
Dockerfile*
.dockerignore
.env*
**/node_modules/
**/__pycache__/
**/test/
**/tests/
Labels & Metadata
Use OCI image-spec labels:
LABEL org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.description="My service"
Health Checks
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["curl", "-f", "http://localhost:8080/healthz"]
For distroless (no curl): compile a static healthcheck binary or use the runtime's built-in health endpoint.
Signal Handling
- Exec form
CMD ["binary"]runs as PID 1 and receives signals directly. - Shell form
CMD binaryruns under/bin/sh -c— SIGTERM goes to shell, not the app. - For scripts as entrypoints:
execto replace the shell, or usetini:RUN apk add --no-cache tini ENTRYPOINT ["tini", "--"] CMD ["node", "server.js"]
New Dockerfile workflow
- [ ] Choose minimal base image with pinned version
- [ ] Create .dockerignore
- [ ] Design multi-stage build (deps -> build -> runtime)
- [ ] Order layers for cache efficiency
- [ ] Add non-root USER
- [ ] Add HEALTHCHECK
- [ ] Add OCI labels
- [ ] Run validation loop (below)
Validation loop
hadolint Dockerfile— fix all warnings (DL=Dockerfile rules, SC=ShellCheck rules)docker build --no-cache -t test .— fix build errorstrivy image testorgrype test— fix CVEs in base image or depsdocker run --rm test— verify the container starts and works- Repeat until hadolint is clean, scan is acceptable, container runs correctly
Deep-dive references
Multi-stage templates: See patterns/multi-stage-templates.md for Go, Node.js, Python, Java, Rust Security hardening: See patterns/security-hardening.md for distroless, secrets, scanning Optimization: See patterns/optimization-patterns.md for cache mounts, BuildKit, image size Instruction gotchas: See instruction-reference.md for per-instruction cheatsheet
Official references
- Dockerfile reference — all instructions, syntax, escape directives
- Docker Build best practices — official guidance on layers, caching, multi-stage
- hadolint rules — DL/SC rule reference
