A Docker image's attack surface is the sum of everything inside it — every OS package, shared library, shell, and utility, whether or not your application uses them. A stock ubuntu:22.04 image ships roughly 80 to 100 packages, a full shell, a package manager, and dozens of coreutils. Your compiled service might touch three of them. The rest are pure liability: each is code that can carry a CVE, and several are tools an attacker will reach for immediately after a code-execution bug. The curl heap-overflow CVE-2023-38545 (October 2023) only mattered to images that bundled curl; the Shellshock family (CVE-2014-6271) only mattered to images with bash. The most reliable way to not be affected by a class of vulnerability is to not ship the code that contains it. Here is how to strip an image down to what your app actually needs.
Step 1: Start from the smallest base that runs your app
Choose the smallest base your runtime allows. A statically linked Go or Rust binary needs nothing but the binary itself and can run on scratch. A dynamically linked service needs libc, which distroless provides without the rest of a distro.
# Statically linked binary: no OS at all
FROM scratch
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
Step 2: Use multi-stage builds to leave the toolchain behind
Compilers, headers, and package managers are build-time tools that have no business in a runtime image. A multi-stage build keeps them in a discarded stage.
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
Step 3: If you must use a full distro, install nothing extra
When a distro base is unavoidable, skip recommended packages, clean the package cache in the same layer, and never install debugging tools "just in case."
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Step 4: Trim the build context
Anything you COPY . into the image is surface, including files you did not mean to ship. A .dockerignore keeps them out of the context entirely.
# .dockerignore
.git
.env
node_modules
tests
docs
*.md
Step 5: Verify what actually shipped
Confirm the image is as empty as you intended. If a shell or package manager is still present, a layer added it back.
# Inspect layers and installed packages
docker history --no-trunc myapp:slim
safeguard scan --image myapp:slim --list-packages
Attack surface by base image
| Base image | Approx. size | Shell? | Package manager? | Typical OS CVEs |
|---|---|---|---|---|
ubuntu:22.04 | ~78 MB | yes | apt | high |
debian:12-slim | ~74 MB | yes | apt | moderate |
alpine:3.20 | ~7 MB | yes (ash) | apk | low |
distroless/base | ~20 MB | no | no | very low |
distroless/static | ~2 MB | no | no | minimal |
scratch | 0 | no | no | none (OS) |
Smaller is not automatically safer for every workload — scratch and distroless remove the debugging tools your on-call engineers rely on — but every package you remove is one you never have to patch or triage.
How Safeguard scans your images
Reducing attack surface shrinks the OS-package half of the problem; it does nothing for the vulnerabilities in your application's own dependencies, which ship in the app layer regardless of base image. Safeguard scans both. It reads components straight from filesystem layers and language manifests — so it produces an accurate inventory even for a scratch or distroless image with no package database — then runs reachability analysis so the findings you act on are the ones an attacker could actually trigger. It generates a CycloneDX or SPDX SBOM for every build and opens auto-fix pull requests when a fixable dependency or base-image bump is available. Run it in CI with the Safeguard CLI, scan images in pull requests with Secure Containers, and cover the dependency tree with software composition analysis. The Safeguard vs Trivy comparison shows how prioritized findings differ from a raw CVE list, and pricing starts at a dollar for a single repository.
Frequently Asked Questions
Does a smaller image always mean fewer vulnerabilities?
Usually, because fewer packages means a smaller population of things that can appear in a CVE feed. But size is a proxy, not a guarantee — a tiny image running a vulnerable version of your application's dependencies is still vulnerable. Reduce surface and scan; do not treat one as a substitute for the other.
Can I use scratch for any application?
Only for fully static binaries, typically Go or Rust compiled with no dynamic linking. Interpreted languages such as Python, Node.js, and Ruby need a runtime and libc, so they use a distroless language-specific image instead of scratch.
How do I debug a container with no shell?
Attach an ephemeral debug container that brings its own tools, for example kubectl debug -it pod --image=busybox --target=app. This gives you a shell alongside the workload without permanently adding one to the runtime image.
Is removing the package manager enough on its own?
It removes a favorite attacker tool and a frequently patched component, which is worthwhile, but it is one control among several. You still need a non-root user, reachability-aware scanning, and dependency updates. Attack-surface reduction lowers the odds; it does not close the door by itself.
Ready to see your image's real surface? Connect a repository at app.safeguard.sh/register, and read the scanning and SBOM setup in the documentation at docs.safeguard.sh.