Skip to content
Back to Deployment

Docker Containerization Best Practices

Image layering, distroless bases, multi-arch builds, and supply-chain security for production containers.

ContainersIntermediate

Why

A naive 1.5GB image built on node:20 with root user and unscanned dependencies is a security incident waiting to happen. Disciplined containerization cuts image size 80%, removes 90% of CVE surface via distroless, and enables multi-arch (amd64+arm64) for cross-platform deploys.

When

Apply these practices to any container heading to production, especially regulated industries (finance, healthcare) needing SBOM and signed images. Skip hardening for local dev images (use docker-compose override).

How

1. Layer ordering — COPY package*.json + RUN npm ci before COPY . . to cache deps across code changes 2. Distroless base — gcr.io/distroless/nodejs20-debian12 has no shell, no package manager, smaller + safer 3. Non-root user — create USER in builder, set in runner; container escape ≠ host root 4. Multi-arch — docker buildx build --platform linux/amd64,linux/arm64 --push (for Apple Silicon + Graviton) 5. SBOM — docker buildx build --sbom=true generates SPDX manifest, attach to image as OCI artifact 6. Sign — cosign sign --key cosign.key myregistry/app:v1 (Sigstore, Kubernetes can verify via policy) 7. Scan — trivy image --severity HIGH,CRITICAL myapp:v1, fail CI on findings; use .trivyignore for accepted risk 8. Runtime hardening — docker run --read-only --cap-drop ALL --security-opt no-new-privileges

Example

# Dockerfile — hardened, multi-arch ready # syntax=docker/dockerfile:1.6 (for --sbom, --provenance) FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev --ignore-scripts FROM gcr.io/distroless/nodejs20-debian12 AS runner WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --chown=nonroot:nonroot dist ./dist COPY package*.json ./ USER nonroot:nonroot EXPOSE 3000 ENTRYPOINT ["node", "dist/server.js"] # .dockerignore — keep build context small node_modules .git *.md .env* dist coverage # Build + push multi-arch + SBOM + sign # docker buildx create --use --name mybuilder # docker buildx build \ # --platform linux/amd64,linux/arm64 \ # --sbom=true --provenance=true \ # -t myregistry/app:v1 --push . # cosign sign --key cosign.key myregistry/app:v1 # trivy image --severity HIGH,CRITICAL --exit-code 1 myregistry/app:v1 # Runtime — read-only rootfs, drop all caps # docker run --read-only --tmpfs /tmp \ # --cap-drop ALL --security-opt no-new-privileges \ # -p 3000:3000 myregistry/app:v1

Common Problems

  • ⚠Distroless breaks debugging — no shell to exec into, use a debug variant or ephemeral sidecar
  • ⚠Multi-arch build is 3x slower — arm64 emulation on amd64 host via QEMU is slow, use native arm64 builder
  • ⚠cosign verification not enforced — images signed but K8s admits unsigned too, add Kyverno/OPA policy
  • ⚠Trivy fails on distroless — no package manager to query, use --scanners vuln with OS DB

Best Practices

  • ✓Pin base image by digest — FROM node:20-alpine@sha256:abc... not :20-alpine, prevents supply-chain swap
  • ✓Use BuildKit cache mounts — RUN --mount=type=cache,target=/root/.npm for fast rebuilds
  • ✓Sign AND verify in cluster admission — Kyverno policy rejects unsigned images at deploy time
  • ✓Keep .dockerignore tight — exclude test fixtures, .git, node_modules to shrink context 10x

FAQ

Multi-stage build — why and how?

It separates the build environment (with devDependencies, compilers) from the runtime image. Build in stage 1, copy only dist/ to stage 2 (FROM node:18-alpine). The final image has no source or build tools — smaller and more secure.

How do I keep secrets out of my Docker image?

Never COPY .env into the image or ARG secrets — they're visible in image layers. Use Docker BuildKit secrets (--secret) or mount at runtime via -e or --env-file. Scan with trivy to verify no secrets leaked.

Why should I pin image versions instead of using :latest?

:latest changes without warning — your build breaks when the base image updates. Pin to a specific digest (node:18-alpine@sha256:abc...) for reproducible builds. Update pins deliberately in a scheduled PR.

How do I optimize Docker layer caching?

Order Dockerfile commands from least to most frequently changing. Copy package.json (rarely changes) before source (changes every commit). Each changed layer invalidates all layers below it.

What's the difference between CMD and ENTRYPOINT?

ENTRYPOINT is the fixed command (e.g., node server.js); CMD is default args that can be overridden (docker run <img> --port 4000). Use ENTRYPOINT when the container is a single-purpose executable, CMD when it's a generic base.