DEV Community

Pawan Shinde
Pawan Shinde

Posted on

The Clean Plate Problem: How Multi-Stage Builds and Distroless Images Cut Container Attack Surfaces to Zero

A few weeks ago, I ran an audit on a backend microservice container running in our staging cluster.The application itself was a compiled Go binary that took up less than 20 MB of disk space. But when I ran docker images, the resulting image weighed in at nearly 1 GB.Even worse, when I attached an interactive terminal into that running container:Bashdocker exec -it payment-service /bin/bash
I found a full-blown Debian Linux environment sitting inside. Compilers, package managers (apt), network downloaders (curl, wget), and an active root shell were all sitting right next to our core application logic.If an attacker managed to find a remote code execution (RCE) bug in that service, I hadn’t just handed them an app bug—I had handed them an entire Linux workstation inside our VPC.Here is how modern container workflows solve this using two industry patterns: Multi-Stage Builds and Distroless Images.1. Multi-Stage Builds: The Restaurant Kitchen AnalogyThink about eating at a restaurant.In the kitchen, the chef uses heavy knives, cutting boards, vegetable peelers, mixing bowls, and blazing stoves. When the waiter brings your order to the dining table, you get a clean plate with the finished meal.You would never expect the waiter to dump the chopping board, raw vegetable peels, greasy pans, and meat scraps directly onto your table.Yet that is exactly what a single-stage Dockerfile does.+-----------------------------------------------------------+
| SINGLE STAGE (Messy Kitchen + Food Shipped Together) |
| Compilers + SDKs + Git + Headers + Source Code + Binary |
| Image Size: ~900 MB |
+-----------------------------------------------------------+
When building an application in Go, Rust, C++, Java, or even Node.js, you need heavy build tools: compilers, package managers, development headers, and build caches. But your production environment only needs the final compiled binary or transpiled assets.The Fix: Multi-Stage SeparationA multi-stage build uses multiple FROM instructions in a single Dockerfile. Each FROM starts a brand-new stage, letting you copy only the artifacts you want from earlier stages.Here is what that looks like in practice:Dockerfile# -------------------------------------------------------------

STAGE 1: "The Kitchen" (Builder stage with compilers and SDKs)

-------------------------------------------------------------

FROM golang:1.23-alpine AS builder

WORKDIR /build

Cache dependency downloads

COPY go.mod go.sum ./
RUN go mod download

Copy source and compile down to a standalone binary

COPY . .
RUN CGO_ENABLED=0 go build -o /build/payment-api .

-------------------------------------------------------------

STAGE 2: "The Dining Table" (Final production runtime)

-------------------------------------------------------------

FROM alpine:3.20

WORKDIR /app

Copy ONLY the compiled binary from Stage 1

COPY --from=builder /build/payment-api /app/payment-api

EXPOSE 8080
CMD ["/app/payment-api"]
The Difference:Single-Stage Image: ~900 MB (Go compiler, source code, build caches, git).Multi-Stage Image: ~15 MB (Tiny Alpine OS layer + compiled binary).Why It Matters:During high-traffic autoscaling events, Kubernetes doesn't have to pull 1 GB images across the network to spin up new pods. A 15 MB image pulls in less than a second, saving cloud bandwidth costs and speeding up recovery times.2. Distroless Images: Stripping Down to the ChassisDropping image size to 15 MB with Alpine is a huge step forward, but from a security perspective, Alpine still ships with an interactive shell (/bin/sh) and a package manager (apk).To understand why this is a risk, consider an analogy:A standard family car comes with air conditioning, leather seats, a radio, a trunk, and a glovebox. A Formula 1 race car strips out everything that doesn't make the car go fast: no back seat, no radio, no air conditioning, no trunk.A Distroless image (originally created by Google) takes that Formula 1 approach to containers. It contains only your application and its minimal runtime dependencies (like glibc or SSL root certificates).It explicitly omits:Package managers (apt, apk, yum)System utilities (curl, wget, ls, grep)Interactive shells (/bin/sh, /bin/bash)Standard Base (Ubuntu/Debian): [ Shell + Tools + Package Manager + App ]
Alpine Base: [ Shell + apk + Minimal OS + App ]
Distroless Base: [ App + Direct Runtime Libs ONLY ]
A Real-World Security ScenarioImagine a critical vulnerability (like an RCE or file-upload exploit) is discovered in a patient records microservice.Scenario A: The Container Runs on Standard UbuntuThe attacker exploits the application bug and executes an arbitrary command.They run curl or apt-get install nmap directly inside the container.They download an external botnet or reconnaissance script.They explore the local container filesystem using ls -la and read configs.They use the running container as a jump box to pivot into the rest of your private network.Scenario B: The Container Runs on DistrolessThe attacker exploits the bug and tries to invoke /bin/sh.The operating system responds:Plaintextexec: "/bin/sh": stat /bin/sh: no such file or directory
The attacker tries calling curl or wget to pull remote tools. Neither exists.The exploit fails because there is no execution environment inside the container to run unauthorized binaries.The Gold Standard: Multi-Stage + DistrolessIn production systems handling sensitive workloads, you can combine both techniques. You build in a full developer SDK environment, but ship on Google's static Distroless image:Dockerfile# Stage 1: Build the code in a full compiler environment
FROM golang:1.23 AS builder

WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

Stage 2: Deploy to Google's Distroless static runtime

FROM gcr.io/distroless/static-debian12

Copy the binary from the builder stage

COPY --from=builder /app/server /server

Distroless provides a built-in non-root user (UID 65532: nonroot)

USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]
What You Get:Tiny footprint: Image sizes drop to the bare minimum required for execution.Non-root by default: Runs under user nonroot, neutralizing container escape techniques that rely on root privileges.No shell attacks: Traditional living-off-the-land techniques (using installed utilities like curl or bash) are impossible.Clean vulnerability scans: Tools like Trivy or Snyk show zero CVEs for unneeded OS packages, because those packages were never installed in the first place.Comparison SummaryConceptWhat It IsProblem It SolvesProduction ImpactMulti-Stage BuildA Dockerfile using multiple FROM stages to separate build tools from runtime artifacts.Bloated image sizes caused by compilers, test suites, and package caches.Cuts storage needs, saves cloud network transfer costs, and speeds up auto-scaling.Distroless ImageA base image containing only the app and direct dependencies—no shell, no package manager.Attackers using internal container shells and OS utilities to pivot into your private network.Eliminates package vulnerabilities and neutralizes shell-based exploit payloads.Wrapping UpBuilding clean containers isn't just about saving a few hundred megabytes of disk space. It fundamentally changes the threat model of your workloads in the cloud.If your production container doesn't need a compiler to run, don't ship one. And if it doesn't need an interactive shell to serve traffic, strip the shell out entirely.Keep the kitchen in the build stage, and bring only the clean plate to production.

Top comments (0)