A reference project demonstrating how to achieve reproducible builds for .NET applications using multi-stage Docker containers. Two builds from the same source commit produce bit-for-bit identical images.
Reproducible builds provide strong guarantees that the binary artifact was built from the declared source code — no hidden modifications, no build-time surprises. This strengthens supply chain security and makes it possible to verify that what runs in production matches what is in source control.
This project demonstrates the key techniques required to achieve this with .NET and Docker.
Reproducibility is achieved through a combination of tools and practices:
| Technique | Purpose |
|---|---|
SOURCE_DATE_EPOCH |
Pins all embedded timestamps to the latest git commit |
DotNet.ReproducibleBuilds |
Enables deterministic compilation in the .NET SDK |
NuGet lock files (packages.lock.json) |
Locks the exact package versions and content hashes |
--provenance=false --sbom=false |
Disables Docker-generated metadata that varies between builds |
├── Dockerfile # Multi-stage build definition
├── build.sh # Local build and verification script
├── .github/workflows/ci.yml # GitHub Actions CI pipeline
└── src/
├── Sample.slnx # Solution file
├── Directory.Build.props # Shared build properties (reproducibility settings)
├── Directory.Packages.props # Centralized NuGet package versions
├── Sample.WebApi/ # ASP.NET Core Web API
├── Sample.Lib/ # Shared class library
└── Sample.WebApi.Tests/ # xUnit test project
The Dockerfile defines multiple stages that enforce quality gates before producing the final image:
node_base
└── spell_check ← Spell-check all C# source files
build_base
├── verify_dotnet_format ← Enforce code formatting (dotnet format)
└── build ← Compile the solution + optional SonarQube analysis
└── tests ← Run xUnit tests
└── tests.linux-x64 ← Export test results (scratch image)
└── publish ← Publish the Web API
└── files.linux-x64 ← Export binaries (scratch image)
└── runtime.linux-x64 ← Final production image
The production image is based on the chiseled ASP.NET runtime — a minimal, distroless-style image that contains only what is needed to run the application.
- Docker with BuildKit enabled (Docker 23+)
- Git
# Build and verify reproducibility
./build.shThe script:
- Extracts
SOURCE_DATE_EPOCHfrom the latest git commit - Builds the production image twice (with slightly different args)
- Compares the two images using
diffocito confirm they are semantically identical
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
# Run spell check only
docker build --target spell_check .
# Build the production image
docker build \
--target runtime.linux-x64 \
--build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \
--provenance=false \
--sbom=false \
-t my-app:latest .docker build \
--target runtime.linux-x64 \
--build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \
--build-arg RUN_SONARQUBE=true \
--secret id=SONAR_TOKEN,env=SONAR_TOKEN \
--secret id=SONAR_ORGANIZATION,env=SONAR_ORGANIZATION \
--secret id=SONAR_PROJECT_KEY,env=SONAR_PROJECT_KEY \
--provenance=false \
--sbom=false \
-t my-app:latest .The GitHub Actions workflow runs on every push and pull request to main. It executes each quality gate stage in sequence and uploads test results and the application binaries as build artifacts.
Caching strategy: NuGet packages, npm packages, and .NET tools are all cached in the GitHub Actions cache, keyed by their respective lock files. This avoids re-downloading dependencies on every run while still invalidating the cache when versions change.
| Package | Version | Purpose |
|---|---|---|
DotNet.ReproducibleBuilds |
2.0.2 | Deterministic .NET compilation |
xunit |
2.9.3 | Unit testing framework |
coverlet.collector |
8.0.0 | Code coverage collection |
Microsoft.AspNetCore.OpenApi |
10.0.3 | OpenAPI support |
cspell (npm) |
9.7.0 | Source code spell checking |
dotnet-sonarscanner |
11.2.0 | SonarQube analysis |
- .NET 10.0 — Target framework
- ASP.NET Core — Web API runtime
- Docker BuildKit — Multi-stage builds with layer caching and secret mounts
- GitHub Actions — CI/CD automation
- SonarQube — Static analysis and code quality
To confirm two builds produce identical images, the project uses diffoci:
./diffoci diff --semantic docker://tag1 docker://tag2A clean output (no semantic differences) confirms the build is reproducible.