Reproducible Container Images With the .NET 11 SDK

devopsdockerdotnet
Reproducible Container Images With the .NET 11 SDK

Set SOURCE_DATE_EPOCH to the same Unix timestamp on two .NET 11 container builds and you can reproduce the image manifest digest. Both jobs must also use the same source, SDK, packages, platform, and base image. You can compare their digests locally before pushing either image to a registry.

Why Would Two Builds of the Same Commit Differ?

Your staging tests pass. The production job checks out the same commit, rebuilds the image, and tags it with the same commit SHA. A tag with the same spelling can still point to a different image in production.

The manifest lists the digests of the image configuration and its layers. If the second job writes a different timestamp into a layer's archive header, the layer digest changes even when the application files are identical. That changes the manifest digest too.

The .NET 11 SDK writes the application files into the layer in a consistent order. For reproducible container builds, give both jobs the same timestamp.

Build Two Images Without Pushing Them

You'll need a Unix shell, the .NET 11 SDK, tar, and jq. Create the service with dotnet new web -n StockApi -f net11.0 --no-https and run cd StockApi.

The SDK downloads the base image. Save each archive one directory above the project so the next build can't include the previous archive as application content.

publish_image() {
  dotnet publish -c Release -r linux-arm64 --no-self-contained \
    -t:PublishContainer \
    -p:ContainerRepository=stock-api \
    -p:ContainerImageFormat=OCI \
    -p:SOURCE_DATE_EPOCH=1794268800 \
    -p:ContainerArchiveOutputPath="../$1.tar.gz"
}

publish_image first
dotnet clean -c Release -r linux-arm64
publish_image second

Both publishes target Linux Arm64 and use the .NET 11 SDK selected on the build machine. Keep the architecture fixed even if the build agents run on different machines. The SDK builds the image without a Dockerfile, and writing to an archive lets you inspect it without registry credentials.

Compare the Manifest Digests

These are single-platform OCI archives, so the first entry in each OCI image index identifies its Linux Arm64 manifest. Extract that digest from index.json in both archives.

image_digest() {
  tar -xOf "../$1.tar.gz" index.json | jq -er '.manifests[0].digest'
}

first_digest=$(image_digest first) &&
second_digest=$(image_digest second) &&
test "$first_digest" = "$second_digest" &&
echo "Same image manifest"

On SDK 11.0.100 the comparison printed Same image manifest. Now change the response string in Program.cs from Hello World! to Inventory ready. and run publish_image changed.

The first 12 digits are enough to see which digests changed in this run. Keep the full digests in the CI comparison, as the shell command does.

BuildManifest digest prefixApplication layer digest prefix
First publishfb520e11f1b6548110c07118
Clean build of the same sourcefb520e11f1b6548110c07118
One response string changed87f16fdc2583f89bb34acb60

To check the application layer yourself, open the manifest referenced by index.json and print its last layer digest.

manifest=$(image_digest changed)
tar -xOf ../changed.tar.gz "blobs/sha256/${manifest#sha256:}" |
  jq -r '.layers[-1].digest'

Which Inputs Should CI Pin?

The hard-coded timestamp lets you run the example outside a Git checkout. In CI, -p:SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" uses the commit timestamp, so a retry tomorrow gets the same value.

The web template defaults to a base tag such as mcr.microsoft.com/dotnet/aspnet:11.0. That tag can point to a different image after the next servicing update.

To reproduce an earlier release, set the container base image with -p:ContainerBaseImage=mcr.microsoft.com/dotnet/aspnet@sha256:... and supply the full base digest from that build. Update the pin when you adopt a patched base image.

Pin the SDK version and package versions too. If a custom build task writes the current time into a generated file, the publisher still packages those bytes.

When two digests differ, compare the base layers first, then the application layer. If every layer matches, compare the image configuration. Its digest is also part of the manifest.

Summary

Deploy the digest staging already tested when you can. For a separate production build, have the deployment job compare its manifest digest with the one that passed staging and stop if they differ.

Frequently Asked Questions

How do I produce a reproducible image with the .NET 11 SDK?

Publish with the PublishContainer target and supply a stable SOURCE_DATE_EPOCH, expressed as Unix seconds. Keep the application, SDK, packages, platform, and base image fixed. Compare the resulting image manifest digests.

Where is the image digest in a .NET SDK container archive?

For a single-platform OCI archive, read index.json and inspect the digest of its manifest descriptor. That identifies the platform image manifest, which references the configuration and layer blobs.

Does setting SOURCE_DATE_EPOCH pin the container base image?

No. A mutable tag can point to a newer base on a subsequent build. Set ContainerBaseImage to an image reference with a digest to freeze that input, and update it deliberately when picking up base image fixes.

Browse all .NET guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.