Multi-stage builds have been part of Docker since version 17.05, released in 2017, and they've become the default way teams keep production images small — compile in one stage, copy only the final binary into a slim runtime stage, and discard everything else. That pattern creates a specific scanning problem: a Dockerfile might reference three or four FROM lines, but only one of them describes the image that actually ships. Snyk Container, like most container scanners, has to figure out which layers matter, which base image to flag for upgrades, and which Dockerfile instruction introduced a given vulnerable package. This post walks through the publicly documented mechanics of how snyk container test handles that process — what it scans by default, what changes when you pass it a Dockerfile, and where the attribution logic has known limits.
What Does Snyk Container Actually Scan in a Multi-Stage Build?
Snyk Container scans the final built image, not the Dockerfile's build instructions as a whole. When you run snyk container test <image>:<tag>, Snyk pulls (or reads locally) the image manifest and its layers exactly as they exist after docker build completes — meaning only the filesystem contents of the last stage (or whichever stage was tagged with -t) are present to inspect. This is a structural consequence of how multi-stage builds work, not a special accommodation Snyk built for them: Docker itself discards intermediate stages from the final image, so a compiler, package manager cache, or dev-only library installed in an early FROM golang:1.22 AS builder stage simply isn't part of the artifact Snyk (or any image scanner) receives. Snyk's job is to enumerate installed OS packages and, if configured, application dependencies inside that final layer set and match them against its vulnerability database.
How Does Snyk Identify the Base Image When There Are Multiple FROM Lines?
Snyk determines the base image from the final stage of the Dockerfile — the stage that produces the image under test — rather than any earlier FROM line used only for building. According to Snyk's documentation, when you supply the Dockerfile alongside the image using the --file flag (for example, snyk container test myapp:1.4 --file=Dockerfile), Snyk parses the file to locate the stage that corresponds to the scanned image and reads its base image reference from that stage's FROM instruction. This is what powers Snyk's "base image recommendations" feature, which suggests newer or less vulnerable tags for the base you're actually shipping (say, recommending node:20.11-alpine3.19 over node:20.11) rather than suggesting an upgrade to a golang:1.22 builder stage that never reaches production.
Does Snyk Attribute Vulnerabilities Back to Specific Dockerfile Instructions?
Yes, but only when the Dockerfile is provided at scan time, and only for the final stage's instructions. Without --file, snyk container test reports vulnerable packages and the image layer digest they came from, but it has no instruction-level context — you get a layer hash, not a line of source. With the Dockerfile supplied, Snyk correlates each layer in the final image to the Dockerfile instruction that created it (a RUN apt-get install, a COPY, an ADD) and surfaces that instruction in the CLI output and in the Snyk UI, so a finding reads as "introduced by line 14: RUN apk add curl=8.5.0-r0" rather than an opaque layer ID. This is the mechanism Snyk calls Dockerfile-layer attribution, and it's scoped to whichever stage actually ends up in the scanned image — instructions in earlier, discarded stages aren't attributed to anything because their layers don't exist in the final artifact.
Are Intermediate Build Stages Scanned at All?
Not by snyk container test against the final image, because those stages' filesystems never exist in the artifact being tested. If a builder stage pulls in a vulnerable version of gcc or a leftover pip cache with an old library, that risk lived only inside the ephemeral build container and was never written into the shipped image's layers — so it falls outside the scope of a scan aimed at "what is running in production." Teams that want visibility into what an early build stage installed generally have two options documented by Snyk and standard in the ecosystem: build and tag the intermediate stage separately (docker build --target builder -t myapp:builder .) and run snyk container test myapp:builder against it directly, or rely on source-composition scanning (snyk test against the manifest files, like package.json or requirements.txt, used in that stage) rather than container scanning. Snyk Container itself is scoped to the artifact, not the build process that produced it.
What Happens When a Final Stage Copies Artifacts from an Earlier Stage?
Snyk scans whatever the COPY --from=<stage> instruction actually places into the final image, but its instruction-level attribution reflects the copy operation, not the original build context that created the artifact. A common multi-stage pattern is COPY --from=builder /app/dist /usr/share/nginx/html, which pulls compiled output from the builder stage into the runtime stage. Snyk sees this as a single instruction in the final stage and will attribute any vulnerable OS packages or detectable application dependencies found in the copied path to that COPY line. What it can't do is retroactively reconstruct which specific RUN command in the builder stage produced those files — the provenance stops at the copy boundary because, again, the builder stage's own layer history isn't part of the final image. This is a known limitation of layer-based attribution generally, not something unique to Snyk, and it means multi-stage Dockerfiles with several nested COPY --from hops can produce attribution that's accurate about "which final-stage instruction," but silent on "which original build step."
How Should Teams Interpret a Clean Scan on a Multi-Stage Image?
A clean snyk container test result on a multi-stage image means the shipped layers had no matched vulnerabilities at scan time — it does not mean the build pipeline that produced those layers was itself free of risk. Because Snyk Container's scope is the final artifact, a multi-stage build that pulls a compromised base image in its builder stage, runs an untrusted curl | bash install script, or fetches a poisoned dependency during compilation can still produce a "clean" final image if the compromise doesn't leave detectable traces (a backdoored binary, a malicious dependency baked into the compiled output) in the shipped layers. This is precisely the gap that image-scanning-only workflows have: the artifact can look fine while the process that built it wasn't verified. It's also why Snyk itself recommends pairing container scanning with source and dependency scanning across every stage, not just the last one.
How Safeguard Helps
Safeguard is built around the assumption that a clean final-image scan is necessary but not sufficient — supply chain risk lives in the build process, not just the shipped artifact. For multi-stage Docker builds specifically, Safeguard tracks provenance across every stage, not only the one that survives into the final layer set: it records what base images, dependencies, and network calls occurred in builder stages that get discarded, so a compromised compiler toolchain or a tampered intermediate dependency doesn't disappear from the audit trail just because Docker didn't ship it. Safeguard also correlates SBOM data across COPY --from boundaries, so when a runtime stage pulls compiled artifacts from a builder stage, the resulting component inventory reflects what actually went into that binary rather than stopping at the copy instruction. Combined with build attestation and CI/CD pipeline verification, this gives teams a way to confirm not just that the final image scans clean, but that the multi-stage build which produced it was itself trustworthy end to end — closing the gap that artifact-only scanning, by design, leaves open. Whether your stack today runs Docker Snyk scanning, Safeguard, or both side by side, that build-process visibility is the piece a Snyk Docker scan alone isn't scoped to provide.