Image Best Practices
Building production-grade Docker images is a craft: minimal base, deterministic builds, layer caching, non-root user, signed releases, and a clear policy on what goes in and what stays out. Smaller, simpler images mean faster deploys, smaller CVE surface, and less surprise.
Slim base, multi-stage, security, signing
EXAMPLE
# 1) Pick the right base
# • alpine — tiny (~5 MB) but musl libc differs from glibc; some native modules trip
# • debian-slim — small (~80 MB), familiar libc, broad compatibility
# • distroless — no shell, no package manager; security gold standard
# • scratch — empty; only for static binaries (Go, Rust release builds)
#
# Always pin by digest, not tag, for reproducible builds:
FROM node:20.10.0-alpine@sha256:abc123...
# 2) Multi-stage — separate build vs runtime
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ['node', 'dist/server.js']
# 3) Optimise layer caching — put stable things first
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # rarely changes — cached layer
RUN npm ci # also cached
COPY src/ ./src/ # changes often — invalidates from here
RUN npm run build
# Code changes don't bust the npm install layer.
# 4) .dockerignore — keep build context lean
node_modules
.git
.env*
coverage
build
dist
logs
*.log
.DS_Store
Readme.md
.idea
.vscode
# Smaller context → faster builds AND no accidental secret leaks.
# 5) Non-root user
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ['node', 'server.js']
# Most images include a non-root user (node, nginx, www-data). USE IT.
# Custom:
RUN addgroup -g 1000 app && adduser -u 1000 -G app -s /bin/sh -D app
USER app
# 6) Readonly root filesystem in Kubernetes
securityContext:
readOnlyRootFilesystem: true
# Pair with writable volumes for tmp/cache:
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- { name: tmp, emptyDir: {} }
# 7) Distroless for compiled languages
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags='-s -w' -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ['/server']
# Final image ~10 MB; no shell to exploit; no package manager to escalate.
# 8) Health checks
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD wget -qO- http://localhost:3000/healthz || exit 1
# Orchestrators (Kubernetes, Compose, ECS) use this signal.
# 9) Labels — metadata for auditing
LABEL org.opencontainers.image.title='My API'
LABEL org.opencontainers.image.source='https://github.com/me/my-api'
LABEL org.opencontainers.image.version='1.2.3'
LABEL org.opencontainers.image.created='2024-01-15T00:00:00Z'
LABEL org.opencontainers.image.revision='abc1234'
LABEL org.opencontainers.image.licenses='MIT'
# 10) BuildKit secrets (don't leak in layers)
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# Secret never lands in the final image OR build cache.
# 11) Build cache mounts — faster repeats
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/var/cache/apt apt-get install -y curl
# 12) Multi-arch builds
docker buildx create --name multi --use
docker buildx build --platform linux/amd64,linux/arm64 -t me/myapp:1.0 --push .
# 13) Scan + sign
# • Trivy / Grype scans → fail the build on CRITICAL/HIGH
# • Cosign signs → consumers verify
cosign sign --key cosign.key me/myapp:1.0
cosign verify --key cosign.pub me/myapp:1.0
# 14) SBOM generation
syft me/myapp:1.0 -o cyclonedx-json > sbom.json
# Or anchore/sbom-action in GitHub Actions.
# 15) Image size targets (ballpark)
# • Static Go / Rust: 10-30 MB
# • Distroless Java: 150-200 MB
# • Alpine Node: 100-150 MB
# • Slim Node: 150-250 MB
# • Full Debian: 500+ MB — usually too big for prod
# Use 'docker history myapp:1.0' to find the fat layer.
# 16) Production checklist
# ✓ Non-root user
# ✓ Multi-stage build; runtime stage minimal
# ✓ Pinned base by digest
# ✓ No secrets baked in (BuildKit secrets only)
# ✓ .dockerignore excludes node_modules, .git, .env
# ✓ HEALTHCHECK present
# ✓ OCI labels for auditing
# ✓ SBOM generated + Cosign-signed
# ✓ Trivy scan in CI; fail on HIGH/CRITICAL
# ✓ Compressed with --compress where appropriate
# 17) Common bugs
# • Forgot .dockerignore → 5 GB build context; 'COPY . .' takes minutes
# • COPY before npm ci → cache busts on every source change
# • Running as root → escapes; always USER non-root
# • Hardcoded secrets in ENV → visible via docker history
# • Pinning :latest in production → unexpected rolls; pin by digest
# • Alpine with native deps (sharp, bcrypt) → segfaults; use debian-slim
# • Build args used for secrets → visible in image history; use BuildKit secrets
# • No HEALTHCHECK → orchestrator can't tell if app is ready
# • Heavy image with everything → CVE surface; cut layers per concern
# • Building each image from a different base → fragmented patching
Why it matters
Production images are multi-stage, minimal, pinned by digest, non-root, health-checked, scanned in CI, and signed. Lean on distroless or scratch for compiled languages, alpine or debian-slim for interpreted ones. Generate SBOMs per build, mount secrets via BuildKit (never bake them in), and use cache mounts so reproducible builds stay fast.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Pin versions, use -alpine for size, .dockerignore your node_modules, # run as a non-root user, multi-stage for builds.Try it Yourself »
Discussion
Loading…