
A typical python:3.12 or node:20 base image ships a full Debian userland: a shell, a package manager, compilers, and hundreds of packages your application never calls. Every one of those is something an attacker can use after a container escape or a dependency compromise. Multi-stage builds let you compile or install your application in a full-featured image, then copy only the finished artifact into a radically smaller final image — distroless or, more extreme still, scratch. This post walks through both, with a real before-and-after comparison of image size and installed packages.
1. The problem with single-stage builds
A naive Dockerfile builds and runs in the same image:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]This works, but the final image contains pip, a C compiler toolchain pulled in by any package with native extensions, apt, a full shell, and whatever other OS packages the base image ships — none of which your running application needs. If an attacker gets code execution inside this container, all of that tooling is available to them too: a shell to explore with, a package manager to pull in more tools, compilers to build exploits.
2. Multi-stage builds: separate "build" from "run"
A multi-stage Dockerfile uses more than one FROM instruction. Each one starts a new, independent build stage. You do the heavy lifting — installing build tools, compiling dependencies — in an early stage, then use COPY --from= to pull only the finished files into a clean final stage.
# ---- build stage ----
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt
COPY . .
# ---- final stage ----
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/deps /app/deps
COPY --from=builder /app /app
ENV PYTHONPATH=/app/deps
CMD ["python", "app.py"]This is already an improvement — pip and any build-time-only tooling never land in the final layer history if you order things carefully — but python:3.12-slim is still a full, if trimmed, Debian system with a shell and a package manager. To actually shrink the attack surface, the final stage needs to not be a general-purpose OS image at all.
3. Distroless: a runtime with (almost) nothing else
Google's distroless images strip out everything that isn't strictly needed to run an application in a given language: no shell, no package manager, no coreutils like ls or cat, just the language runtime and its direct OS-level dependencies (glibc, certs, timezone data where relevant).
# ---- build stage ----
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt
COPY . .
# ---- final stage ----
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /app/deps /app/deps
COPY --from=builder /app /app
ENV PYTHONPATH=/app/deps
CMD ["app.py"]Note there's no shell form here — distroless images have no /bin/sh, so CMD must use the exec form (a JSON array) and point straight at the Python entrypoint built into the image, not a shell command string. If you try CMD python app.py (shell form) against a distroless base, it fails because there's no shell to interpret it.
For a compiled Go binary, the pattern is even cleaner, since a static Go binary has no runtime dependencies at all beyond CA certificates:
# ---- build stage ----
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server
# ---- final stage ----
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]distroless/static-debian12 is the smallest distroless variant — just certs, timezone data, and a minimal set of OS files, with no language runtime at all, appropriate for statically linked binaries like Go with CGO_ENABLED=0.
4. Scratch: nothing but your binary
FROM scratch is the literal empty image — zero layers, zero files, not even a base OS. It only works for a fully static binary with no dynamic library dependencies at all, because there's no filesystem to resolve .so files against, no certs, nothing.
# ---- build stage ----
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server
# ---- final stage ----
FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]This builds, but it will fail the moment your binary tries to make an outbound HTTPS call, because there's no CA certificate bundle in the image to validate the TLS connection. Fix that by copying certs explicitly from the builder stage:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /app /app
ENTRYPOINT ["/app"]Scratch is a poor fit for anything written in Python, Node, or Ruby — those runtimes depend on an interpreter, standard library files, and usually dynamically linked system libraries that scratch simply doesn't have anywhere to put. Distroless is the realistic floor for interpreted languages; scratch is realistic only for statically compiled binaries (Go, Rust with the musl target, C with static linking).
5. A real size and package comparison
Here's what the three approaches look like side by side for a small Go HTTP service, measured with docker images and a package inventory of each base layer:
IMAGE SIZE SHELL PACKAGE MANAGER COMPILER TOOLCHAIN
golang:1.22 (single-stage) ~900 MB yes apt yes
debian:12-slim (multi-stage) ~80 MB yes apt no
gcr.io/distroless/static-debian12 ~2 MB no no no
scratch ~0 MB no no no(Exact sizes vary by architecture and the specific image tag/digest you pull — always check docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" yourself rather than trusting a number from a blog post.)
The security-relevant columns are the last three, not the size. A smaller image is a nice side effect — faster pulls, less registry storage — but what actually reduces attack surface is the absence of a shell and a package manager. An image with no shell can't run an interactively-dropped reverse shell payload the normal way. An image with no package manager can't have a persistence tool installed into it post-compromise. Size alone is a proxy for "how much is in here that I didn't audit," which is a reasonable heuristic but not the actual security property you want.
To see what's actually installed in a given base image before you commit to it, pull it and inspect its filesystem directly:
# list every file in an image without running it
docker create --name tmp-inspect gcr.io/distroless/static-debian12
docker export tmp-inspect | tar -tv | head -50
docker rm tmp-inspect
# or, for images that do have a package manager, list installed packages
docker run --rm debian:12-slim bash -c "dpkg -l | wc -l"6. Debugging a shell-less container
The obvious objection to distroless and scratch is: how do you debug a container with no shell when something goes wrong in production? A few real options:
- Ephemeral debug containers (Kubernetes).
kubectl debug -it <pod> --image=busybox --target=<container>attaches a throwaway container with a shell to the same process namespace, without modifying the running image. - Distroless
:debugtags. Many distroless images publish a parallel-debugtag (for examplegcr.io/distroless/python3-debian12:debug) that includes a busybox shell, meant for local troubleshooting — swap to it temporarily, reproduce the issue, then go back to the non-debug tag for anything that ships. - Structured logging and metrics instead of exec access. The more robust long-term fix is to not need a shell in production at all — ship enough application-level logging, tracing, and metrics that you rarely need to exec into a container to understand what it's doing.
Common pitfalls
- Using shell-form
CMD/RUNin a shell-less final stage.CMD python app.pysilently depends on/bin/shexisting. Use the exec JSON-array form:CMD ["python", "app.py"], and make sure that form is valid for the specific distroless variant you chose (some pin a specific interpreter path you must invoke directly). - Forgetting CA certificates on scratch. Any outbound HTTPS call from a scratch-based image fails with a certificate verification error unless you explicitly copied
ca-certificates.crtin from the builder stage. - Dynamically linked binaries on scratch. A Go binary built without
CGO_ENABLED=0, or a Rust binary linked against glibc instead of musl, will fail to start on scratch with an error about a missing shared library, because there's no libc in the image to resolve against. - Leaving build secrets or source in the final stage. Multi-stage builds only help if you actually stop copying things from the builder stage once you've got what you need. A careless
COPY --from=builder /app /appthat copies the whole build directory, including a.gitfolder or a.envfile used only at build time, defeats the purpose. - Assuming smaller always means safer. A tiny image with an outdated, vulnerable static binary is still vulnerable. Multi-stage and distroless reduce the attack surface available after a compromise; they don't replace keeping your actual application dependencies patched.
Troubleshooting
If your final-stage build fails with exec: "python": executable file not found in $PATH, check that you're using the exec-form CMD/ENTRYPOINT with the full binary path distroless expects, not a shell-form command. If a distroless container exits immediately with no visible error, re-run it against the matching -debug tag locally to get a shell and see what's actually failing — distroless images intentionally produce minimal error output since there's no shell to render tracebacks interactively in some cases. If a scratch-based binary fails to start with something like no such file or directory even though the binary is clearly present, that's almost always a dynamic linking problem — run file ./your-binary in the build stage and confirm it says "statically linked"; if it says "dynamically linked," rebuild with CGO_ENABLED=0 (Go) or the musl target (Rust).
Where to go next
Once your final images are built from distroless or scratch, the next layer of defense is scanning them for known-vulnerable packages with a tool like Trivy or Grype as part of CI, and running the container itself with a read-only root filesystem and a non-root user (distroless images already default to a non-root nonroot user in most variants — check with docker inspect). Combine that with the image itself having nothing to attack, and a compromised dependency has a much harder time turning into a persistent foothold.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


