Loading_
Loading_
A reference multi-stage Dockerfile plus an audit script that reports layer sizes, finds secrets baked into history and fails a build that ships a package manager.
# syntax=docker/dockerfile:1.7## Reference multi-stage build. Node example; the shape is language-agnostic.# Result: no shell, no package manager, no build toolchain, non-root. # ── Build stage ───────────────────────────────────────────────────────────FROM node:22-bookworm-slim AS build WORKDIR /src # Dependency layer first so it caches independently of source changesCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev --ignore-scripts # Now the source, which changes on every commitCOPY . .RUN npm run build && npm prune --omit=dev # ── Runtime stage ─────────────────────────────────────────────────────────# Distroless: no shell, no apt, no busybox. If an attacker lands here there# is nothing to pivot with.FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime WORKDIR /app # Copy only what actually runsCOPY --from=build --chown=nonroot:nonroot /src/dist ./distCOPY --from=build --chown=nonroot:nonroot /src/node_modules ./node_modulesCOPY --from=build --chown=nonroot:nonroot /src/package.json ./ USER nonrootEXPOSE 3000 ENV NODE_ENV=production \ NODE_OPTIONS="--max-old-space-size=384" # Distroless has no shell, so exec form is mandatoryCMD ["dist/server.js"] # ── Metadata ──────────────────────────────────────────────────────────────LABEL org.opencontainers.image.source="https://github.com/acme/service" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.base.name="gcr.io/distroless/nodejs22-debian12" # ══════════════════════════════════════════════════════════════════════════# Companion audit script — save as audit-image.sh# ══════════════════════════════════════════════════════════════════════════## #!/usr/bin/env bash# set -euo pipefail# IMAGE="${1:?usage: audit-image.sh IMAGE:TAG}"# FAIL=0## echo "== Layer sizes =="# docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" |# sort -h -r | head -12## echo# echo "== Total =="# docker image inspect "$IMAGE" --format '{{.Size}}' |# awk '{ printf "%.1f MB\n", $1/1024/1024 }'## echo# echo "== Checks =="## # A runtime image should not contain a package manager# if docker history --no-trunc "$IMAGE" | grep -Eq '(apt-get|apk add|yum install)'; then# if ! docker history --no-trunc "$IMAGE" | grep -Eq 'rm -rf /var/lib/apt/lists'; then# echo "FAIL: package manager used without cleaning its cache in the same layer"# FAIL=1# fi# fi## # Secrets baked into build args are visible in history forever# if docker history --no-trunc "$IMAGE" |# grep -Eiq '(password|secret|token|api[_-]?key)=[^ ]+'; then# echo "FAIL: a credential appears in image history"# FAIL=1# fi## # Running as root is the default, and the default is wrong# USER_SPEC=$(docker image inspect "$IMAGE" --format '{{.Config.User}}')# if [[ -z "$USER_SPEC" || "$USER_SPEC" == "root" || "$USER_SPEC" == "0" ]]; then# echo "FAIL: image runs as root"# FAIL=1# fi## # Healthcheck absence is not fatal but is always worth saying out loud# if [[ "$(docker image inspect "$IMAGE" --format '{{.Config.Healthcheck}}')" == "<nil>" ]]; then# echo "WARN: no HEALTHCHECK defined"# fi## [[ $FAIL -eq 0 ]] && echo "PASS" || exit 1Most oversized images come from three things: building in the runtime stage, running a package manager without cleaning its cache in the same layer, and copying a whole build context because .dockerignore was never written.
The Dockerfile here is the pattern we deploy: a build stage with the toolchain, a distroless runtime stage with only the artefact, a non-root user, and no shell in the final image.
The audit script inspects the built image history for common mistakes — secrets in ARG values, an apt cache left behind, a runtime stage still containing a compiler — and returns non-zero so it gates a pipeline.
The platform turns any script into a governed automation — versioned, gated, audited and reversible.