When the image is the bill of materials
Your security team asks which OpenSSL build sits inside the API pod running in production. The on-call engineer opens the registry tag, sees a 680 MB image built six months ago from a Dockerfile that starts with mcr.microsoft.com/dotnet/sdk:8.0, and realises the answer requires guessing. No SPDX file was attached to the release. The container runs as root because nobody changed the default user. That is not a supply-chain programme; it is hope dressed as DevOps.
Multi-stage builds for .NET are not a Dockerfile aesthetic choice. They define what attackers inherit when a credential leaks, how fast you can patch a transitive CVE, and whether production carries compilers, test harnesses, and shell utilities that have no business on the runtime path. Teams that treat the image as the unit of delivery should treat the build graph as part of security architecture.
Problem space: fat images, slow response
Consider a typical ASP.NET Core minimal API service: one project, one NuGet graph, deployed to Kubernetes behind an ingress. A single-stage build that uses the SDK image as the runtime base produces an artefact between 600 MB and 900 MB depending on restore caches and whether publish output includes debug symbols. Pull time on a cold node adds 45–90 seconds. Scanner output lists hundreds of packages, many from the SDK layer the application never executes at runtime.
Quantified targets we use on delivery reviews:
- Runtime image size under 120 MB for a trimmed ASP.NET API (Alpine or distroless runtime base)
- Non-root UID 10001 or higher, with
USERset in the final stage only - SPDX JSON attached to every release tag before deploy promotion
- Critical CVE gate: zero unfixed criticals in the runtime stage layer graph at merge time
Source systems in the build pipeline include NuGet.org, private feed artefacts, base images from Microsoft Container Registry, and any native libraries pulled via dotnet publish. Constraints include air-gapped registries, cosign signing requirements, and customer questionnaires that now ask for SBOM format explicitly.
Why the naive Dockerfile fails
The pattern below is still common in internal templates copied from 2019 tutorials. It is valid Docker syntax and invalid production hygiene.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS base
WORKDIR /app
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
EXPOSE 8080
ENTRYPOINT ["dotnet", "/app/publish/MyApi.dll"]
What breaks at scale is measurable rather than theoretical. Image pulls dominate rollout time when HPA adds ten pods during a traffic spike; a 680 MB layer set multiplied by ten nodes is not free bandwidth. Vulnerability scanners flag every tool in the SDK image; triage meetings burn hours separating runtime-relevant CVEs from build-time noise. If an attacker escapes the application process and the container runs as root, they inherit package managers and shells that make lateral movement trivial.
Microsoft documents multi-stage patterns in the Docker multi-stage build guide, but teams often stop at copying publish output without trimming, without SBOM export, and without separating build secrets from runtime layers.
Engineered approach: build, attest, run minimal
A production-shaped pipeline uses three logical stages: restore and publish with the SDK, optional SBOM generation on the publish output, and a runtime stage that contains only the ASP.NET runtime plus published binaries. Signing and scan gates sit between CI build and registry promotion.
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
RUN dotnet publish "MyApi/MyApi.csproj" \
-c Release -o /app/publish \
/p:PublishTrimmed=true \
/p:PublishSingleFile=false
FROM build AS sbom
RUN dotnet tool install --global CycloneDX --version 3.0.0 \
&& /root/.dotnet/tools/dotnet-CycloneDX /src/MyApi/MyApi.csproj \
-o /sbom -f spdxjson
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final
WORKDIR /app
RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app
COPY --from=build /app/publish .
COPY --from=sbom /sbom/*.spdx.json /app/sbom/
USER app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
Trimming reduces IL size and removes unused assemblies from the publish folder. Alpine runtime bases shrink the OS footprint compared to Debian variants, at the cost of musl compatibility testing for native dependencies. If you ship Skia, gRPC with native stubs, or bundled Chromium, validate in CI rather than assuming trim-safe.
SPDX is the interchange format many enterprise procurement templates reference. The SPDX specification defines fields for package name, version, supplier, and checksum. Attach the JSON file to the image filesystem and upload the same artefact to your release record so responders can diff SBOMs between versions during an incident.
CI gates: scan, sign, promote
Build pipelines should fail before a tag reaches production if the runtime stage introduces a critical CVE or if the SBOM step was skipped. Example GitHub Actions fragment:
- name: Build and export SBOM
run: |
docker build --target sbom -t myapi:sbom .
docker build -t myapi:${{ github.sha }} .
docker scout cves myapi:${{ github.sha }} --only-severity critical --exit-code
- name: Attach SBOM to release
run: |
docker create --name extract myapi:${{ github.sha }}
docker cp extract:/app/sbom ./sbom
docker rm extract
cosign sign-blob --bundle sbom.spdx.json sbom/myapi.spdx.json
Operational proof is a before/after table from your registry, not a slide deck claim. After migrating a client API from single-stage SDK images to the pattern above, we recorded 680 MB → 94 MB image size, node pull p95 62 s → 11 s on a three-node pool, and CVE triage time for monthly patches dropping from roughly four hours to twenty minutes because scanners scoped to the runtime stage only.
Non-root and read-only root filesystem
Setting USER in the final stage is necessary but not sufficient. Kubernetes should mount an ephemeral volume for /tmp if the runtime writes temp files, and set securityContext.readOnlyRootFilesystem: true where the application permits it. ASP.NET Core respects ASPNETCORE_TEMP; point it to a writable mount. Init containers that fix permissions must not reintroduce root at pod start unless your platform team documents an exception.
Distroless variants (mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled) remove shells entirely. Debugging is harder; prefer them for stable APIs where logs export via stdout and diagnostics use OpenTelemetry sidecars rather than docker exec.
Migration steps that survive audit
- Inventory current production tags: size, user ID, presence of SDK binaries (
dotnet --list-sdksinside a throwaway pod should fail on runtime images). - Add multi-stage Dockerfile on a branch; run parity tests and load tests against the new image.
- Introduce SBOM generation on the build stage; store artefacts for thirty days minimum.
- Enable scanner gate on critical severity; negotiate SLA with app teams for high findings.
- Roll out non-root in staging, then prod; watch for permission errors on file uploads and certificate stores.
- Document rollback: keep previous tag for one release cycle with signed SBOM attached.
Supply-chain attestations beyond SPDX
SPDX answers “what packages exist.” Customers increasingly ask “who built this image and from which commit.” Attach build provenance with OCI referrers or your registry’s attestation API: Git SHA, pipeline run ID, base image digest at build time. When a CVE lands, you can answer whether production ever pulled the vulnerable digest or skipped it because promotion failed the scout gate.
Secret leakage during build is a separate class of incident. Multi-stage builds help because the final stage never copies .git, .env, or test credentials from the build context if .dockerignore excludes them and COPY scopes stay narrow. BuildKit secret mounts (--secret) keep NuGet tokens out of layer history. Audit a random production image quarterly: run dive or equivalent and confirm no SDK paths, no shell history, no stray PEM files.
Registry retention policy matters for rollback. Keep the last three signed tags per service with SBOM artefacts in object storage independent of the registry. If the registry vendor suffers an outage, responders still have the SPDX JSON to compare against a rebuilt image from the same Git tag.
When bespoke build pipelines still make sense
Standard templates cover most ASP.NET APIs. Custom work remains justified when you bundle native inference runtimes, mix .NET with Node front-end build stages in one repo, or must reproduce hermetic builds inside regulated environments that forbid network access during docker build. Those cases need explicit stage boundaries and cached artefact feeds, not a longer single-stage Dockerfile.
If your team is standardising container supply chain for .NET services across product lines, a structured review of build graphs, SBOM tooling, and deploy gates is usually faster than patching each repository in isolation. See our custom software development practice for how we run those engagements without turning the exercise into shelfware.

