Multi-Stage Docker Builds — Secrets Survive rm
Even after 'rm', secrets persist in Docker layers—token was recovered by inspecting history.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Production DevOps experience
- ✓Deep understanding of the tool's internals
- ✓Experience debugging distributed systems
- Core mechanism: Each
FROMstarts a new stage. Only files explicitly copied viaCOPY --from=end up in the final image. Everything else — compilers, build caches, source code — is discarded. - Size impact: Typical reduction from 1.2 GB (single-stage) to 80-150 MB (multi-stage). The build tools never ship.
- Layer caching: BuildKit caches each stage independently. Changing application code does not invalidate the dependency-install stage.
- BuildKit parallelism: Stages with no dependency between them execute in parallel, cutting CI time.
- Security: Build secrets (API keys, tokens) used in early stages never appear in the final image layers.
- Biggest mistake: Forgetting that only explicitly COPYed artifacts survive. If you build a binary in stage 1 but forget to COPY it in stage 2, the final image has nothing to run.
Multi-stage Docker builds are a Dockerfile syntax feature that lets you define multiple FROM statements in a single Dockerfile, each starting a new build stage. The critical insight: only the final stage’s filesystem ends up in the resulting image. Earlier stages can contain compilers, dependencies, and intermediate artifacts, but they’re discarded unless explicitly copied.
This solves the fundamental problem that RUN rm in a Dockerfile is useless for reducing image size — Docker layers are immutable, so deleting a file in a later layer just hides it; the bytes still exist in the image. Multi-stage builds avoid that entirely by never including the layer that contained the junk in the first place.
Each stage can be based on a different base image. A common pattern: stage one uses golang:1.21-alpine to compile a binary, stage two starts from scratch or distroless and just COPY --from=0 the binary. The compiler, Go toolchain, and any temp files are gone.
You can name stages and target them with --target during build, so docker build --target=test runs your test stage (with test dependencies and test binaries) while docker build --target=production produces the lean runtime image. This lets a single Dockerfile serve dev, CI, and production without duplication.
Under the hood, BuildKit (the default builder since Docker 23.0) parallelizes independent stages and supports cache mounts (--mount=type=cache) that persist package downloads across builds without bloating the image. For Go, you get images under 10 MB.
For Node.js, you can compile and prune in a full image, then copy only node_modules and the built app into a slim Alpine base, cutting 300 MB images to ~150 MB. For Java, you compile with a JDK image and copy the fat JAR into a JRE-only runtime. The size reduction is dramatic — a Go hello-world drops from 800 MB (with the Go SDK) to under 2 MB.
Multi-stage builds are the standard way to produce production Docker images; if you’re still running apt-get clean && rm -rf /var/lib/apt/lists/* and hoping it helps, you’re fighting the layer model instead of using it.
Imagine you're baking a cake. You need mixing bowls, electric beaters, and measuring cups to make it — but when you serve the cake to guests, you don't put all that equipment on the plate with it. Multi-stage Docker builds work the same way: one stage is your messy kitchen where all the building happens, and the final stage is just the clean, finished cake. Your users get the cake. The mixing bowls stay in the kitchen — and never ship to production.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every second your Docker image takes to pull across a network is a second your deployment is stalled. In Kubernetes environments rolling out hundreds of pods under load, or CI pipelines building dozens of images a day, bloated images are a reliability and cost problem. A Node.js app shipping with its full devDependencies, TypeScript compiler, and build toolchain alongside the production binary is a 1.2 GB image waiting to become a 3 AM outage.
Traditional single-stage Dockerfiles are all-or-nothing. You install the compiler, build the binary, copy the source — and all of it ends up baked into the final layer. Docker does not have a native concept of 'clean up after yourself' within a single build context, because every RUN instruction adds a new immutable layer. Removing files in a later layer does not reclaim space — it just hides them.
Multi-stage builds solve this by introducing multiple isolated build contexts inside one Dockerfile. Each FROM starts a fresh stage with its own filesystem. Only artifacts you explicitly copy forward survive into the final image. The build tools, intermediate object files, and source code are discarded with the build stage. This is the single most impactful Dockerfile optimization for production images.
Why rm in a Dockerfile Doesn't Shrink Your Image
Multi-stage Docker builds let you compile code in one stage and copy only the final artifact into a minimal runtime image. The core mechanic: multiple FROM statements in a single Dockerfile, each starting a new stage, and COPY --from=target to extract files from earlier stages. This eliminates the need to chain RUN apt-get remove or rm commands that never actually reduce image size because each RUN layer is additive — deleting a file in a later layer only marks it as hidden, not removed. The final image still contains the full weight of every intermediate layer.
A typical Java workflow: stage one uses a full JDK image (e.g., eclipse-temurin:17-jdk) to compile and package a Spring Boot fat JAR. Stage two starts from a slim JRE image (e.g., eclipse-temurin:17-jre-alpine) and copies only the JAR from stage one. The resulting image is ~180 MB instead of ~400 MB. No apt-get purge, no rm -rf /var/lib/apt/lists/* — just a clean COPY. Each stage can use different base images, different working directories, and even different OS families.
Use multi-stage builds whenever your build process requires tools absent from the runtime image — compilers, build tools, test runners. This is every production Java service, every microservice, every CI pipeline. The alternative is either a bloated image with build-time dependencies or a fragile script that tries to clean up after itself. Multi-stage is the only pattern that guarantees a minimal, reproducible runtime image without sacrificing build-time tooling.
How Multi-Stage Builds Work at the Layer Level
A Dockerfile with multiple FROM instructions creates multiple isolated stages. Each stage starts with a fresh filesystem initialised from its base image. Stages are identified by their index (0, 1, 2...) or by an alias assigned with AS.
The critical insight: only files you explicitly COPY --from=<stage> are transferred between stages. Everything else — compilers, build caches, intermediate object files, source code — exists only in the build stage's filesystem and is discarded when the build completes. The final image contains only the last stage's filesystem plus any files copied into it.
Docker's layer system means each RUN, COPY, and ADD instruction creates an immutable layer. In a single-stage build, RUN rm file creates a NEW layer that hides the file — the original layer with the file still exists in the image. Multi-stage builds avoid this entirely by never including the file in the final stage's layers in the first place.
# ───────────────────────────────────────────────────────────── # Stage 1: Build stage — contains Go compiler, source, dependencies # This stage is ~800 MB but NEVER ships to production # ───────────────────────────────────────────────────────────── FROM golang:1.22-alpine AS builder WORKDIR /app # Copy dependency files FIRST — this layer is cached until go.mod changes COPY go.mod go.sum ./ RUN go mod download # Copy source code AFTER dependencies — changing source doesn't invalidate dep cache COPY . . # Build a statically linked binary — no runtime dependencies needed # CGO_ENABLED=0 ensures no C library dependency # -ldflags='-s -w' strips debug symbols, reducing binary size by ~30% RUN CGO_ENABLED=0 GOOS=linux go build \ -ldflags='-s -w' \ -o /app/server \ ./cmd/server # ───────────────────────────────────────────────────────────── # Stage 2: Runtime stage — contains ONLY the binary and config # This stage is ~15-25 MB — a 97% reduction from the build stage # ───────────────────────────────────────────────────────────── FROM alpine:3.19 AS runtime # Install CA certificates for HTTPS and tzdata for timezone support RUN apk --no-cache add ca-certificates tzdata WORKDIR /app # Copy ONLY the compiled binary from the builder stage # Everything else (Go compiler, source, build cache) is discarded COPY --from=builder /app/server . # Copy config files if needed COPY --from=builder /app/config ./config # Run as non-root user — security best practice RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser EXPOSE 8080 ENTRYPOINT ["./server"]
- Each FROM creates a new isolated filesystem
- Data moves between stages ONLY via COPY --from
- Build stage is destroyed after build — its layers never ship
- Final image = last stage filesystem only
- Secrets in early stages cannot leak into final stage unless explicitly copied
COPY . .) before installing dependencies. When any source file changes, Docker invalidates the COPY layer and every subsequent layer — including the dependency installation layer. Effect: Every code change triggers a full npm install or go mod download, adding 30-120 seconds to build time depending on dependency count. In CI, this multiplies across dozens of daily builds. Action: Copy dependency manifests (package.json, go.mod, pom.xml) BEFORE copying source code. Install dependencies in a layer that only invalidates when the manifest changes. This single reordering typically cuts rebuild time by 60-80%.FROM scratch (~0 MB) or FROM alpine (~7 MB) with ca-certificates for HTTPS.FROM node:20-slim (~200 MB). Alpine may fail on native modules that require glibc.FROM eclipse-temurin:21-jre-alpine (~100 MB). JRE only, not JDK — the compiler is not needed at runtime.FROM python:3.12-slim (~150 MB). Alpine's musl libc fails on C extensions.FROM scratch with a statically linked binary. No shell, no attacks — but no debug tools either.Stage Targeting with --target: Production vs Build vs Test
Docker allows you to stop the build at any named stage using the --target flag. This is invaluable for development workflows, CI, and debugging. Without --target, Docker builds all stages up to the last FROM. With --target, you can request only the stages you need.
- Development: Build only up to the
depsorbuilderstage, which includes all dev dependencies and tooling. Developers get a container with live-reload tools (e.g., nodemon, reflex) without waiting for the full production image to build. - Testing: Build a
teststage that runs unit tests, linters, and security scans. CI can pull this stage, run tests, and discard it without ever building the final production image. - Production: Build the final runtime stage (
runtimeorproduction) that contains only what ships to production.
- Use
AS <name>on everyFROMyou may want to target. - The last
FROMthat isn't used as a base for other stages is the default target. - Build command:
docker build --target production -t myapp:latest .
# ───────────────────────────────────────────────────────────── # Multi-stage Dockerfile with explicit stage targets # Build with: docker build --target <stage> -t image . # ───────────────────────────────────────────────────────────── # Stage 0: Base dependencies (shared across build and dev) FROM node:20-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci # Stage 1: Development — includes dev dependencies and tools FROM base AS development RUN npm ci --include=dev COPY . . RUN npm install -g nodemon CMD ["nodemon", "src/index.js"] # Stage 2: Test — runs tests with coverage FROM base AS test RUN npm ci --include=dev COPY . . RUN npm test # Stage 3: Build — compiles TypeScript, builds assets FROM base AS build COPY . . RUN npm run build # Stage 4: Production — minimal runtime image FROM node:20-alpine AS production WORKDIR /app # Copy only production dependencies from base COPY --from=base /app/node_modules ./node_modules # Copy built application from build stage COPY --from=build /app/dist ./dist COPY --from=build /app/package.json ./ USER node CMD ["node", "dist/index.js"]
--target, only the stages up to and including the target are built. However, if an earlier stage is not needed by the target, Docker skips it entirely — but any side effects (like secrets used) never run. This means you must ensure the target stage's dependency chain includes all necessary intermediate stages. For example, if production stage copies from build stage, you cannot target production without building build first — Docker does that automatically.docker build --target test to build only the test stage. Run tests in that image, then docker build --target production only on successful test passes. This cuts CI pipeline time by 30-50% for typical workflows.docker build --target <stage> gives you surgical control over which stages execute. Use it to create efficient CI pipelines that separate test from production builds, and to give developers fast feedback images. Name every stage you might want to target.--target development or --target dev. This stage has full toolchain and dev dependencies.--target test. Build only dependencies + test execution stage. Faster feedback.--target production (or the last stage, which is the default). Minimal, secure, no build tools.--target builder or the stage that fails. Quickly inspect state with docker run -it <stage-image> /bin/sh.BuildKit, Parallel Stages, and Cache Mounts
BuildKit is Docker's modern build engine, enabled by setting DOCKER_BUILDKIT=1 or using docker buildx. It brings three critical capabilities to multi-stage builds:
- Parallel stage execution: Stages that do not depend on each other run concurrently. If your Dockerfile has a test stage and a build stage that both depend on the dependency-install stage, BuildKit runs test and build in parallel after dependencies are installed.
- Cache mounts:
--mount=type=cachepersists a directory across builds without invalidating the layer. This is transformative for package managers — mount the npm/pip/go cache directory so dependency downloads are cached across builds even when the layer would otherwise be invalidated. - Secret mounts:
--mount=type=secretprovides a temporary file during RUN execution that is never stored in any layer. This is the correct way to use API keys, tokens, and credentials during builds.
# syntax=docker/dockerfile:1 # required for BuildKit features # ───────────────────────────────────────────────────────────── # Stage 1: Dependency installation with cache mount # The npm cache persists across builds — re-installs are near-instant # ───────────────────────────────────────────────────────────── FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ # --mount=type=cache persists /root/.npm across builds # Even if this layer is invalidated, the cache is not lost RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev # ───────────────────────────────────────────────────────────── # Stage 2: Build stage with secret mount for private registry # The npm token is available during build but never stored in a layer # ───────────────────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ # Secret mount: token is available as a file during this RUN only # It is NEVER stored in any layer — not even in a hidden layer RUN --mount=type=secret,id=npm_token,target=/run/secrets/npm_token \ --mount=type=cache,target=/root/.npm \ NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci COPY . . RUN npm run build # ───────────────────────────────────────────────────────────── # Stage 3: Production runtime — minimal image # ───────────────────────────────────────────────────────────── FROM node:20-alpine AS runtime WORKDIR /app # Copy only production dependencies from deps stage COPY --from=deps /app/node_modules ./node_modules # Copy built application from builder stage COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./ # Run as non-root RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser EXPOSE 3000 CMD ["node", "dist/index.js"] # ───────────────────────────────────────────────────────────── # Build command with secret: # DOCKER_BUILDKIT=1 docker build \ # --secret id=npm_token,src=.npmrc_token \ # -t myapp:latest .
--mount=type=cache for package manager caches. Combine with proper layer ordering (dependency manifest before source code). This typically cuts CI build time from 3-5 minutes to 30-60 seconds for incremental changes.--mount=type=secret. Available as a file during RUN, never stored in any layer.--mount=type=cache targeting the package manager cache (e.g., /root/.npm, /root/.cache/pip).COPY --from=intermediate in the final stage.Production Patterns: Go, Node.js, and Java
Each language ecosystem has specific multi-stage build patterns that address its unique characteristics. The patterns below are battle-tested in production and handle the most common failure modes.
# ───────────────────────────────────────────────────────────── # Java Multi-Stage Build: Maven build + JRE runtime # Reduces image from ~800 MB (JDK) to ~120 MB (JRE alpine) # ───────────────────────────────────────────────────────────── # Stage 1: Build with Maven FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /app # Copy POM first — this layer is cached until pom.xml changes COPY pom.xml . # Download dependencies with cache mount RUN --mount=type=cache,target=/root/.m2 \ mvn dependency:go-offline -B # Copy source and build COPY src ./src RUN --mount=type=cache,target=/root/.m2 \ mvn package -DskipTests -B # Stage 2: Extract JRE runtime FROM eclipse-temurin:21-jre-alpine AS runtime WORKDIR /app # Copy only the fat JAR from the build stage COPY --from=builder /app/target/*.jar app.jar # JVM tuning for containers — critical for production # -XX:+UseContainerSupport respects cgroup memory limits # -XX:MaxRAMPercentage=75% uses 75% of container memory for heap ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC" EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
- Copy dependency manifest (pom.xml, go.mod, package.json) FIRST
- Install/download dependencies in a separate layer
- Copy source code AFTER dependencies are installed
- Build/compile in the final layer
- Changing source code only invalidates the build layer, not the dependency layer
FROM scratch (0 MB). Binary runs directly. COPY ca-certificates from alpine for HTTPS.FROM node:20-alpine (~120 MB). Fastest pull, smallest footprint.FROM node:20-slim (~200 MB). Alpine's musl libc breaks some native modules.FROM eclipse-temurin:21-jre-alpine (~120 MB). JRE only, not JDK.FROM python:3.12-alpine (~50 MB). Minimal footprint.FROM python:3.12-slim (~150 MB). Alpine requires recompiling C extensions.Image Size Reduction Comparison Table
One of the strongest arguments for multi-stage builds is the dramatic reduction in image size. Below is a comparison of typical image sizes for common language stacks using single-stage vs. optimal multi-stage builds. These numbers are based on production images (source code + dependencies + runtime) and assume best practices like using slim/alpine bases and stripping debug symbols.
The pattern is consistent: build tools and compilers account for 70-90% of image bulk. Multi-stage builds eliminate them from the final image, leaving only the runtime and the compiled artifacts.
- Go and Rust can use FROM scratch – often < 20 MB
- Node.js minimum ~120 MB (node:20-alpine), but includes Node runtime
- Java JRE alpine ~120 MB, but JDK adds ~700 MB
- Python slim ~150 MB, but C extensions add 200-400 MB
Multi-Stage Build Example: Go (Minimal Pattern)
This section provides a focused, minimal multi-stage Dockerfile for a Go application. It demonstrates the canonical pattern: build stage containing the compiler and source, and a runtime stage with only the binary. The result is a 97% reduction in image size.
Key steps: 1. Use golang:alpine as the build stage – includes Go compiler and standard library, but is smaller than full debian-based images. 2. Copy go.mod/go.sum first – layer caching ensures go mod download runs only when dependencies change. 3. Build with CGO_ENABLED=0 – produces a statically linked binary that does not depend on glibc, allowing FROM scratch as runtime. 4. Use -ldflags='-s -w' – strips debug symbol tables, reducing binary size by ~30%. 5. Runtime base: scratch – zero bytes, no shell, no package manager. Maximum security and minimal size.
# syntax=docker/dockerfile:1 # Minimal Go multi-stage build # Stage 1: Build the Go binary FROM golang:1.22-alpine AS builder WORKDIR /build # Download dependencies first - leverages layer caching COPY go.mod go.sum ./ RUN go mod download # Copy source code COPY . . # Build a static binary, strip debug symbols RUN CGO_ENABLED=0 GOOS=linux go build \ -ldflags='-s -w' \ -o /build/server \ ./cmd/server # Stage 2: Runtime - absolutely minimal FROM scratch AS runtime # Copy CA certificates for HTTPS calls (from builder stage) COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Copy the binary COPY --from=builder /build/server /server # No shell, no package manager, no user (not needed - scratch has /dev/null etc.) EXPOSE 8080 ENTRYPOINT ["/server"]
- CGO_ENABLED=0 produces a fully static binary
- scratch has no shell, no utilities – secure but limited
- Add ca-certificates from builder for HTTPS calls
- For debugging, use ephemeral debug containers
file server (should say 'statically linked').FROM scratch. Smallest and most secure. Downside: no shell for debugging.FROM alpine:3.19 (5-7 MB) and install only what you need. Avoid debian-based images unless necessary.FROM gcr.io/distroless/base. Includes glibc, tzdata, but no shell or package manager. Good balance.Security: Secret Management and Image Scanning
Multi-stage builds are a security primitive, not just a size optimisation. The build stage isolation means secrets used during compilation never appear in the final image — IF you use the correct mechanisms. The wrong mechanism (ENV, ARG, or inline RUN) leaks secrets into layers permanently.
Three rules for secret management in Docker builds: 1. Never use ENV or ARG for secrets — they persist in image metadata. 2. Never hardcode secrets in RUN commands — they persist in layer history. 3. Always use BuildKit secret mounts — --mount=type=secret provides temporary file access without layer persistence.
Beyond secrets, the final image should be scanned for vulnerabilities. Even a minimal alpine base image may contain packages with known CVEs. Trivy, Grype, and Snyk Container can scan images in CI and block deployment if critical vulnerabilities are found.
# syntax=docker/dockerfile:1 # ───────────────────────────────────────────────────────────── # WRONG: Secrets in ENV or ARG — visible in docker history and inspect # ───────────────────────────────────────────────────────────── # DO NOT DO THIS: # ENV NPM_TOKEN=ghp_xxxxxxxxxxxx ← visible in docker inspect # ARG DB_PASSWORD=secret123 ← visible in docker history # RUN echo $NPM_TOKEN > .npmrc ← visible in layer history # ───────────────────────────────────────────────────────────── # CORRECT: BuildKit secret mounts # ───────────────────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ # Secret is mounted as a file at /run/secrets/npm_token # Available only during this RUN command # Not stored in any layer — not even hidden RUN --mount=type=secret,id=npm_token,target=/run/secrets/npm_token \ --mount=type=cache,target=/root/.npm \ NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci COPY . . RUN npm run build # ───────────────────────────────────────────────────────────── # Final stage: no secrets, no build tools, no source code # ───────────────────────────────────────────────────────────── FROM node:20-alpine AS runtime WORKDIR /app COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./ USER node CMD ["node", "dist/index.js"] # ───────────────────────────────────────────────────────────── # Build command: # DOCKER_BUILDKIT=1 docker build \ # --secret id=npm_token,src=./.npm_token_value \ # -t myapp:latest . # # Verify no secrets in image: # docker history myapp:latest | grep -i token # should return nothing # docker inspect myapp:latest | grep -i token # should return nothing
docker inspect output even if they are not used in the final stage. If you use ARG NPM_TOKEN in a build stage, the value is stored in the image's metadata JSON. Use --mount=type=secret instead — secrets mounted this way are never stored in any image metadata, layer history, or intermediate filesystem.docker inspect or docker history on the image. In shared registries, this means every developer and every CI system with pull access can extract production secrets. Action: Use BuildKit secret mounts exclusively. Add Trivy or Grype scanning to CI to detect accidentally embedded secrets. Run docker history <image> as a post-build verification step.--mount=type=secret,id=token to mount the secret as a file during RUN. Never stored in any layer.docker run -e VAR=value or Kubernetes env vars — not in the Dockerfile.docker history <image> then docker inspect <image>. Search for leaked tokens. Add Trivy to CI.Name Your Build Stages or Get Burned in Debugging
Most devs treat stage names like commit messages — useless until you need them. Don't be that person. Naming stages with descriptive aliases isn't for clarity in the Dockerfile. It's for survival when you're chasing a 3AM build failure.
When you reference --from=build instead of --from=0, you can read your CI logs without a decoder ring. But the real win comes from docker build --target=test. That single line lets you run integration tests in a throwaway stage without rebuilding the entire pipeline. Your production image stays untouched. Your CI minutes stay human.
Here's the rule: every stage that produces something you might inspect, test, or copy from needs a name. build, test, dev-deps, production. If it only exists to install compilers, leave it unnamed — it's dead code the moment it finishes.
The senior move? Use stage names as documentation. If I see FROM golang:1.21 AS vet-lint, I know exactly what that stage does. If I see FROM node:18 AS build, I know the author has been burned by FROM 0.
// io.thecodeforge — devops tutorial # BAD: Unnamed stages, numeric references FROM golang:1.21 AS 0 RUN go build -o /app/service . FROM alpine:3.19 COPY --from=0 /app/service /service # GOOD: Named stages, explicit targets FROM golang:1.21 AS build-service WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/service . FROM golang:1.21 AS test COPY --from=build-service /src /src RUN go test ./... -v FROM alpine:3.19 AS production COPY --from=build-service /app/service /service CMD ["/service"]
COPY --from=0 in a multi-stage build, changing the order of any stage above it will silently break your build. Always use names. Always.Go Distroless or Go Home: Why Scratch Isn't Enough
You've shrunk your Go binary from 800MB to 12MB using multi-stage builds. Congratulations. Now what? Your scratch image has no shell, no ls, no curl. A perfectly minimal attack surface. But also a perfectly useless debugging environment when production misbehaves.
Distroless images from Google (gcr.io/distroless/base) give you the middle ground: they contain essential runtime dependencies — glibc, SSL certificates, timezone data — but no package manager, no shell, no exploit-friendly utilities. Your 12MB Go binary on scratch just became 16MB on distroless. That 4MB overhead buys you the ability to run openssl checks and properly resolve DNS in Kubernetes.
Here's the decision tree: If your app needs SSL certificates (HTTP clients, database connections, gRPC), use distroless. If your app runs as PID 1 and never talks to the outside world, scratch is fine. But if you ever need to exec into a container and troubleshoot — distroless gives you just enough rope without the noose.
The real hack? Start with distroless, then strip back to scratch only when you've profiled exactly what's unused. Most teams overestimate their binary's independence and underestimate debugging time.
// io.thecodeforge — devops tutorial FROM golang:1.21 AS build WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/payment-processor . # Option A: Scratch (minimal) FROM scratch AS scratch-production COPY --from=build /app/payment-processor /payment-processor CMD ["/payment-processor"] # Option B: Distroless (safe) FROM gcr.io/distroless/base-debian12 AS distroless-production COPY --from=build /app/payment-processor /payment-processor CMD ["/payment-processor"] # Distroless image size: 16MB vs scratch: 12MB # But distroless has SSL certs, ca-certificates, tzdata, no shell
gcr.io/distroless/base as your base and only copy your binary. It's maintained by Google's security team — you can't beat that for free.The Chunky Single-Stage Build
A single-stage Dockerfile installs everything—compiler, test tools, dev dependencies, and runtime—into one image. Each RUN command adds a new layer, but even if you delete files in the same layer, the file data remains in the previous layer, bloating the final image. The real problem: your production container carries a compiler and build artifacts it never executes. This violates the principle of least privilege and expands the attack surface. A chunky build also slows CI, because every layer change invalidates downstream caches. When you push a minor code fix, Docker rebuilds layers from the first change, not from the optimized base. The fix isn't more rm commands—it's separating build and runtime into distinct stages. Single-stage builds feel simple but cost you security, speed, and image hygiene.
// io.theforge — devops tutorial // Chunky single-stage: compiler + runtime in one image FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN go build -o app . # Bloat: contains compiler, test tools, source code ENTRYPOINT ["./app"]
How to Implement Multi-Stage Builds
Multi-stage builds let you use multiple FROM statements in one Dockerfile. Each FROM begins a new stage, and only files you explicitly COPY from previous stages survive to the final image. The pattern: build stage installs dependencies and compiles; runtime stage starts from a minimal base (e.g., alpine, distroless) and copies only the binary. This drops image size by 80-90% and removes tooling that attackers could exploit. You can name stages—builder, test, production—and target them with --target. Implementation requires no extra tooling; Docker's built-in parser handles it. Start by moving all build steps (go build, npm install, mvn package) into a stage named build. Then create a production stage with COPY --from=build. Validate with docker history to see only runtime layers survive.
// io.theforge — devops tutorial // Multi-stage: minimal final image FROM golang:1.21 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o bin/app . FROM alpine:3.19 RUN apk add --no-cache ca-certificates COPY --from=build /src/bin/app /app ENTRYPOINT ["/app"]
--from=build copies from your host filesystem, not the build stage—your final image will be incomplete or huge.Secrets Leaked in Docker Image Layers Exposed API Keys to Public Registry
RUN rm /root/.npmrc after npm install would remove the token from the image. They did not understand that Docker layers are immutable — the rm creates a new layer that hides the file, but the original layer containing the token is still present in the image history./root/.npmrc in one RUN layer, npm install ran in the next layer, and RUN rm /root/.npmrc ran in a third layer. The rm command only marked the file as deleted in the new layer — the token was still recoverable from the earlier layer by inspecting docker history or extracting layers manually. The image was pushed to a public ECR registry without secret scanning.--mount=type=secret to mount the npm token as a temporary file that never persists in any layer.
2. Added DOCKER_BUILDKIT=1 to CI to enable BuildKit secret mounts.
3. Added Trivy and Hadolint scanning to CI pipeline — both detect secrets in image layers.
4. Rotated the compromised npm token immediately.
5. Made the registry private and added IP-based access controls.- Docker layers are immutable.
RUN rmdoes not remove data — it creates a new layer that hides the file. The original data is still in the image. - Never embed secrets in RUN commands. Use BuildKit secret mounts (
--mount=type=secret) or multi-stage builds where the secret stage is discarded. - Always run image security scanning (Trivy, Grype, Snyk Container) in CI before pushing to any registry.
- Public registries are hostile environments. Assume every layer will be inspected by an attacker.
COPY --from=builder path matches the actual build output location. Common mistake: building with WORKDIR /app in the build stage but copying from /build/output in the final stage. Check docker run --rm -it <build-stage-image> ls /app to see what the build stage actually produced.FROM node:20 as the runtime base pulls in the full Node.js SDK (~900 MB). Switch to FROM node:20-slim (~200 MB) or FROM node:20-alpine (~120 MB). Also verify that devDependencies are not installed in the final stage — use npm ci --omit=dev.DOCKER_BUILDKIT=1 or add "features": {"buildkit": true} to /etc/docker/daemon.json. Also check if layer caching is being invalidated by copying files too early in the Dockerfile.docker history <image> output.RUN --mount=type=secret,id=npm_token npm install and pass the secret at build time with --secret id=npm_token,src=.npmrc. Verify with docker history that the secret does not appear.docker history <image> --no-trunc # inspect layer sizesdocker run --rm <image> du -sh /* # find large directoriesdocker run --rm <build-stage> ls -la /app/dist # check build outputdocker inspect <final-image> # check Entrypoint and Cmddocker build --no-cache -t test . # compare with cached build timedocker build --progress=plain -t test . 2>&1 | grep CACHED # see which layers hit cachedocker history <image> | grep -i token # check for leaked secretsdocker run --rm <image> cat /root/.npmrc # check if secret file existsdocker buildx version # check if buildx is availableDOCKER_BUILDKIT=1 docker build --progress=plain . # check for parallel stage execution| Aspect | Single-Stage Build | Multi-Stage Build |
|---|---|---|
| Dockerfile structure | One FROM instruction | Multiple FROM instructions, each starting a new stage |
| Final image contents | Everything: compilers, source, build tools, runtime | Only runtime artifacts explicitly copied from build stages |
| Typical image size | 800 MB – 1.5 GB | 20 MB – 200 MB (90%+ reduction) |
| Secret handling | Secrets persist in layers unless manually removed (unsafe) | Secrets in build stages never appear in final image |
| Layer caching | Single cache chain — any change invalidates downstream layers | Per-stage caching — dependency stage cached independently of build stage |
| BuildKit parallelism | Not applicable — single sequential build | Independent stages execute in parallel |
| Security surface | Large — shell, package manager, compilers all present | Minimal — only runtime dependencies, often no shell |
| Debugging in production | Easy — full toolchain available in container | Harder — use ephemeral debug containers or distroless+debug images |
| CI build time | Slow — full rebuild on any code change | Fast — dependency layer cached, only build layer reruns |
| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile.go-multistage | FROM golang:1.22-alpine AS builder | How Multi-Stage Builds Work at the Layer Level |
| Dockerfile.target-example | FROM node:20-alpine AS base | Stage Targeting with --target |
| Dockerfile.buildkit-advanced | FROM node:20-alpine AS deps | BuildKit, Parallel Stages, and Cache Mounts |
| Dockerfile.java-multistage | FROM maven:3.9-eclipse-temurin-21 AS builder | Production Patterns |
| Dockerfile.go-minimal | FROM golang:1.22-alpine AS builder | Multi-Stage Build Example |
| Dockerfile.secure-pattern | FROM node:20-alpine AS builder | Security |
| StageNaming.yml | FROM golang:1.21 AS 0 | Name Your Build Stages or Get Burned in Debugging |
| DistrolessPattern.yml | FROM golang:1.21 AS build | Go Distroless or Go Home |
| SingleStageWarning.yml | FROM golang:1.21 AS builder | The Chunky Single-Stage Build |
| MultiStagePattern.yml | FROM golang:1.21 AS build | How to Implement Multi-Stage Builds |
Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
A Dockerfile with multiple FROM instructions, each starting an isolated build stage. The build stage contains compilers and tools. The final stage contains only the runtime artifacts copied from the build stage via COPY --from. Build tools never ship in the final image.
Typically 90-97% smaller. A Go application goes from ~800 MB (full golang base) to ~20 MB (scratch with static binary). A Node.js application goes from ~1.2 GB (full node base with devDependencies) to ~150 MB (alpine with production dependencies only).
Multi-stage builds work without BuildKit, but you lose cache mounts, secret mounts, and parallel stage execution. BuildKit is required for the security and performance features that make multi-stage builds production-grade. Always enable it.
Use ephemeral debug containers: kubectl debug -it <pod> --image=busybox --target=<container>. Or add a debug stage that includes debugging tools and use docker build --target debug when you need it. Never ship debugging tools in the production image.
Yes. docker-compose supports multi-stage Dockerfiles natively. You can also specify a target stage with target: runtime in the build section to stop at a specific stage (useful for development builds that need the full toolchain).
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Docker. Mark it forged?
7 min read · try the examples if you haven't