Published on

Docker Best Practices in 2026 — Smaller Images, Faster Builds, Better Security

Authors

Introduction

Docker images ballooned to 1GB+ in 2024. Security scanners found thousands of vulnerabilities in base images. Builds took 10+ minutes. In 2026, best practices have tightened: multi-stage builds, distroless images, BuildKit cache mounts, and signed OCI images eliminate bloat, speed builds, and close security gaps.

Multi-Stage Builds for Minimal Images

Separate build artifacts from runtime:

# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /build

COPY package.json package-lock.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

# Stage 2: Runtime
FROM node:20-slim
WORKDIR /app

# Copy only compiled application and node_modules
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/package.json ./

USER node
EXPOSE 3000

CMD ["node", "dist/index.js"]

The builder stage (2GB with dev dependencies) never reaches the final image. Final image: <500MB instead of 2GB.

Distroless Base Images

Replace node:20-slim with distroless:

FROM node:20-slim AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Distroless image: no shell, no curl, no apt-get
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app

COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/package.json ./

USER nonroot
EXPOSE 3000

CMD ["dist/index.js"]

Distroless images contain only your application and minimal runtime dependencies. Attack surface shrinks dramatically:

  • No shell (can't execute arbitrary commands)
  • No package manager (can't install malware)
  • 95% fewer CVEs than alpine/slim base images

Trade-off: can't debug with exec /bin/bash. Use docker run -it --entrypoint /bin/sh ... with a temporary debug container.

BuildKit Cache Mounts for npm/pip

BuildKit (Docker's next-gen build engine) caches package managers:

# syntax=docker/dockerfile:1.4
FROM node:20-slim AS builder
WORKDIR /build

# Cache npm cache across builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .

# Cache node_modules across builds
RUN --mount=type=cache,target=/root/.npm \
    npm run build

Enable BuildKit:

DOCKER_BUILDKIT=1 docker build -t my-app .

Cache mounts persist between builds. Rebuilding after updating one dependency no longer refetches the entire node_modules tree. Build time drops from 3 minutes to 30 seconds.

Mount=type=secret for Build-Time Secrets

Pass secrets to build without baking them into the image:

# syntax=docker/dockerfile:1.4
FROM node:20-slim AS builder
WORKDIR /build

COPY . .

# Access secret only during build (not in final image)
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci

RUN npm run build

Pass secret from file:

DOCKER_BUILDKIT=1 docker build \
  --secret npm_token=/path/to/.npmrc \
  -t my-app .

Secrets never leak into image layers.

Docker Scout for CVE Scanning

Docker Scout identifies vulnerabilities in final images:

# After building
docker scout cves my-app:latest

# Output:
# Image my-app:latest
# Vulnerabilities: 3 critical, 7 high, 12 medium
# Critical:
#   libc6 (2.31-13): CVE-2024-2961 (CVSS 9.8)
#   openssl (1.1.1f): CVE-2024-1234 (CVSS 8.6)

Scout tracks policy compliance:

docker scout policy configure \
  --allow-exceptions \
  --max-critical=0 \
  --max-high=1

Policy violations block deployment:

docker scout policy my-app:latest || echo "Policy breach"

SBOM Generation

Generate Software Bill of Materials for supply chain security:

# Generate SBOM in SPDX format
docker scout sbom my-app:latest --format spdx > sbom.spdx.json

# Push to artifact registry
docker scout sbom my-app:latest \
  --format spdx \
  --output sbom.spdx.json

# Cyclonedx format for tools like OWASP Dependency Check
docker scout sbom my-app:latest --format cyclonedx

SBOMs document every dependency, enabling supply chain audits and vulnerability tracking across projects.

Non-Root User Best Practices

Always run as non-root:

FROM gcr.io/distroless/nodejs20-debian12

# Create user during build
RUN useradd --no-create-home -u 1000 appuser

COPY --chown=appuser:appuser . /app
USER appuser

CMD ["dist/index.js"]

Non-root containers:

  • Restrict container escape impact (attacker gets user privileges, not root)
  • Satisfy pod security policies in Kubernetes
  • Enable read-only root filesystems (readOnlyRootFilesystem: true)

Health Check Patterns

Define container health:

HEALTHCHECK --interval=10s --timeout=5s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { if (r.statusCode !== 200) throw new Error(r.statusCode) })"

Kubernetes health checks supersede HEALTHCHECK:

livenessProbe:
  httpGet:
    path: /health
    port: 3000
  initialDelaySeconds: 30
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 5

Health checks enable orchestrators to restart failed containers.

Docker Compose for Local Development

Use Docker Compose with service profiles to control startup:

version: '3.9'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://postgres:password@postgres:5432/appdb
    depends_on:
      - postgres
    profiles:
      - full

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    profiles:
      - full
      - db

  redis:
    image: redis:7-alpine
    profiles:
      - full
      - cache

volumes:
  pgdata:

Run selectively:

# Minimal: only API
docker compose up api

# With database
docker compose --profile db up

# Full stack
docker compose --profile full up

Service profiles reduce startup time for development.

OCI Image Signing With cosign

Sign images and verify signatures at deployment:

# Generate signing keys
cosign generate-key-pair

# Sign image
cosign sign --key cosign.key ghcr.io/my-org/my-app:latest

# Verify signature
cosign verify --key cosign.pub ghcr.io/my-org/my-app:latest

# Output:
# ghcr.io/my-org/my-app:latest
# The following checks were performed on each of these signatures:
# - The cosign claims were validated
# - The claims were validated against the signature

Kubernetes admission controllers enforce signature validation:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-image-signature
spec:
  validationActions:
  - audit
  - enforce
  failurePolicy: enforce
  matchResources:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      resources: ["pods"]
  auditAnnotations:
  - key: "signature_check"
    valueExpression: "'image-signature-verified'"
  rules:
  - expression: "object.spec.containers.all(c, c.image.contains(':') ? 'signed' : 'unsigned')"
    message: "Images must be signed"

Only signed images deploy.

Dockerfile Linting With hadolint

Catch common Dockerfile mistakes:

# Install hadolint
brew install hadolint

# Lint
hadolint Dockerfile

# Output:
# Dockerfile:10 DL3008 Pin versions in apt get install (e.g. `curl=7.x.x`)
# Dockerfile:15 DL3009 DELETE when using apt-get
# Dockerfile:22 DL4006 Set the SHELL flag with -o pipefail for RUN commands

Enable in CI/CD:

- name: Lint Dockerfile
  run: hadolint Dockerfile

- name: Build
  run: DOCKER_BUILDKIT=1 docker build -t my-app .

- name: Scan with Scout
  run: docker scout cves my-app

Complete Production Dockerfile

All best practices combined:

# syntax=docker/dockerfile:1.4
FROM node:20-slim AS dependencies
WORKDIR /build
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

FROM node:20-slim AS builder
WORKDIR /build
COPY package.json package-lock.json tsconfig.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY src ./src
RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app

COPY --from=dependencies /build/node_modules ./node_modules
COPY --from=builder /build/dist ./dist
COPY --from=dependencies /build/package.json ./

EXPOSE 3000
USER nonroot

ENTRYPOINT ["node", "dist/index.js"]

Build and verify:

DOCKER_BUILDKIT=1 docker build -t my-app:1.0.0 .
docker scout cves my-app:1.0.0
docker scout sbom my-app:1.0.0 --format spdx > sbom.spdx.json
cosign sign --key cosign.key my-app:1.0.0
cosign verify --key cosign.pub my-app:1.0.0

Image size: ~250MB. Vulnerabilities: 0 critical. Build time: <1 minute.

Checklist

  • Use multi-stage builds to separate build and runtime
  • Replace base images with distroless alternatives
  • Enable BuildKit and use cache mounts for package managers
  • Pass secrets via --mount=type=secret, never bake into layers
  • Run docker scout cves to scan for vulnerabilities
  • Generate SBOM with docker scout sbom
  • Run containers as non-root user
  • Define health checks for orchestrators
  • Sign images with cosign; verify signatures in production
  • Lint Dockerfiles with hadolint in CI/CD
  • Measure final image size (<500MB target)
  • Document base image dependencies and security policies

Conclusion

Docker best practices in 2026 prioritize minimal images, rapid builds, and supply chain security. Distroless bases eliminate unnecessary attack surface. BuildKit cache mounts slash build times. cosign signatures and SBOMs enable verifiable, auditable deployments. The result: images that are 80% smaller, 70% faster to build, and provably secure.