Safeguard
DevSecOps

Reducing Docker image build time without sacrificing security

Multi-stage builds and minimal base images can cut CI build times dramatically — and they're the same changes that shrink your CVE attack surface.

Safeguard Research Team
Research
Updated 6 min read

Docker introduced multi-stage builds in version 17.05, back in 2017, and nearly a decade later most teams still ship images that carry a full compiler toolchain, a package manager, and a shell into production because nobody removed the single-stage Dockerfile written the day the project started. That single decision compounds: a bloated final image takes longer to push and pull in every CI run, and it hands a scanner — and an attacker — hundreds of extra binaries to worry about. Teams that build a Docker image for Kubernetes feel this most acutely, since the same bloated image gets pulled onto every node in the cluster, not just once in CI. The instinct is to treat "faster CI" and "more secure" as a tradeoff, something you balance against each other. In practice the two goals point at almost the same set of changes. A smaller image is faster to transfer and has less to patch. A cached, deterministic build is both quicker and more reproducible, which is itself a supply-chain property. This post walks through four concrete levers — multi-stage builds, BuildKit's caching model, dependency-layer ordering, and minimal or hardened base images — and explains why each one saves CI minutes and CVE count in the same commit.

Why do multi-stage builds shrink both build time and attack surface?

Multi-stage builds let a Dockerfile define a "build" stage with the full SDK, compilers, and dev headers needed to produce an artifact, then COPY --from=<stage> only that artifact into a fresh, minimal final stage. Everything from the build stage — gcc, node_modules dev dependencies, apt package lists, cached wheel files — never reaches the image that ships. Docker's own documentation has recommended this pattern since it landed in 17.05, and the effect on image size is dramatic in typical cases: a Go binary built in a golang:1.22 stage and copied into scratch or distroless/static can shrink from several hundred megabytes to under 20MB. Smaller images push and pull faster in every CI job and every node in a Kubernetes cluster, which is a direct build-time and deploy-time win. The security win is just as direct: a compiler and a package manager in your production image are two more things a scanner has to evaluate and two more things an attacker can abuse if they get a shell — multi-stage builds remove them by construction, not by policy.

How does BuildKit's caching model cut CI minutes without touching the final image?

BuildKit, Docker's build engine, has been the default backend since Docker 23.0 and was available as an opt-in since 18.09. It runs independent stages in parallel instead of sequentially, and it supports RUN --mount=type=cache to persist package-manager caches — ~/.npm, ~/.cache/pip, /var/cache/apt — across builds without baking those files into any image layer. That distinction matters: a naive Dockerfile that runs apt-get update && apt-get install re-downloads packages on every CI run unless the whole layer cache-hits, and if you try to cache it the naive way by not cleaning up, you bloat the final image instead. A BuildKit cache mount gets you the download-once speed benefit while keeping the cache itself entirely out of the shippable image, so build time drops without adding a single extra byte — or a single extra file to scan — to what goes to production.

Does the order of instructions in a Dockerfile actually matter for CI speed?

Yes, and it's one of the highest-leverage, lowest-effort changes available. Docker's layer cache invalidates a layer, and every layer after it, the moment any file referenced by that layer's instructions changes. Copying your entire source tree before running a dependency install means the dependency-install layer reruns on every single code change, even a one-line edit to a README. The standard fix — copy only package.json/package-lock.json, requirements.txt, or go.mod/go.sum first, run the install step, then copy the rest of the source — means dependency installation only reruns when the manifest actually changes, which for most repositories is a small fraction of commits. This is purely a caching technique, not a security control on its own, but it's foundational: every other optimization in this post assumes the dependency-install layer is stable enough to actually get cache-hit in CI rather than invalidated by unrelated source edits.

Why do minimal and distroless base images help with both goals at once?

A standard ubuntu or debian base image ships a shell, a package manager, coreutils, and dozens of libraries your application never calls — every one of them is a potential CVE that shows up in an SCA scan, and every one adds bytes that slow down every pull. Google's gcr.io/distroless images strip all of that out, shipping only your language runtime and its direct dependencies with no shell and no package manager at all, which both shrinks the image substantially and removes an entire category of post-compromise tooling an attacker could otherwise rely on. Alpine, built on musl libc instead of glibc, is another popular minimal option at roughly 5MB for the base — but it's worth a real caveat rather than a blanket recommendation: musl's DNS resolver and libc behavior differ from glibc's in ways that have caused genuine, documented compatibility bugs in some Node.js, Python, and Go binaries that assume glibc semantics. Test against your actual runtime before switching, and don't treat "smaller" as automatically synonymous with "drop-in compatible."

Why does pinning by digest improve both build reliability and security at the same time?

FROM node:20 resolves to whatever the node:20 tag currently points to — a moving target that can change without notice and, in a compromised-registry scenario, could be repointed to a malicious image entirely. FROM node:20@sha256:... pins to one immutable content hash: the same bytes, every build, forever. That immutability is what CI caching depends on to be correct — a cache keyed to a floating tag can silently serve a stale layer against a base image that has since changed underneath it, producing builds that are fast but wrong. Pinning by digest fixes the caching correctness problem and closes a real supply-chain gap in the same line of code, which is exactly the pattern this whole post is describing: the fix for slow, flaky builds and the fix for a fuzzy trust boundary are frequently the identical change.

How Safeguard helps

Safeguard's Gold Registry publishes pre-hardened container base images — registry.safeguard.sh/gold/node:20, registry.safeguard.sh/gold/python:3.12, and others — built to carry zero critical and zero high CVEs across their full dependency tree, with a CycloneDX SBOM, a SLSA Level 3+ build attestation, and a Sigstore signature attached to every artifact. Swapping FROM node:20-alpine for a Gold image is a direct, drop-in instance of the thesis in this post: fewer packages and fewer known vulnerabilities means less for a scanner to flag and less for CI to wait on, while identical APIs and binaries mean no application-level rework. Gold images are pinnable by digest for reproducible builds, and every artifact is continuously revalidated against new CVE feeds, so a base image that's clean today doesn't quietly drift out of compliance six weeks from now.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.