New to containers? Start with Container Image Fundamentals to understand the basics before diving into this guide.
Building .NET Applications with CleanStart
CleanStart provides hardened .NET base images (cleanstart/dotnet) with .NET 8.0, 7.0 (LTS) with security patches, C# compiler and NuGet package management pre-installed, multi-stage build support, FIPS 140-3 compliance available, and optimization for ASP.NET Core and console applications.
The CleanStart two-factory architecture ensures that all NuGet dependencies are verified through the Package Factory before image assembly in the Image Vault, providing cryptographic attestation of dependency provenance for your .NET applications.
Quick Start: An ASP.NET Core Application
Step 1: Create Your Project
mkdir my-dotnet-appcd my-dotnet-appCreate Program.cs:
using Microsoft.AspNetCore.Builder;using Microsoft.Extensions.Logging; var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders();builder.Logging.AddConsole(); var app = builder.Build(); app.MapGet("/", () => new { message = "Hello from CleanStart", version = "1.0" });app.MapGet("/health", () => new { status = "healthy", version = "1.0" }); app.Run("http://0.0.0.0:8080");Create my-dotnet-app.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <PublishReadyToRun>true</PublishReadyToRun> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App" Version="8.0.0" /> </ItemGroup> </Project>Step 2: Create a CleanStart YAML Declaration
Create cleanstart.yaml to declare your application for the two-factory build process:
apiVersion: cleanstart/v1kind: ImageDeclarationmetadata: name: my-dotnet-app version: "1.0"spec: runtime: base: cleanstart/dotnet:8.0 sdk: dotnet:8.0 dependencies: source: "./my-dotnet-app.csproj" resolveWith: "nuget" build: stages: - name: "builder" commands: - "dotnet restore" - "dotnet build -c Release" - "dotnet publish -c Release -o ./out" - name: "runtime" artifacts: - source: "./out" destination: "/app" security: packageFactory: verify: true signingPolicy: "strict" container: user: 1000 readOnlyRootFilesystem: trueThis declaration ensures NuGet packages pass through the Package Factory, where each dependency is scanned and verified before being incorporated into your final Image Vault artifact.
Step 3: Create Production Dockerfile
# Build stageFROM cleanstart/dotnet:8.0 AS builder WORKDIR /appCOPY my-dotnet-app.csproj .RUN dotnet restore COPY . .RUN dotnet publish -c Release -o ./out # Production stageFROM cleanstart/dotnet:8.0 WORKDIR /app # Create non-root userRUN useradd -m -u 1000 appuser # Copy published application from builderCOPY --from=builder /app/out ./ USER appuserEXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 ENV ASPNETCORE_URLS="http://+:8080"CMD ["dotnet", "my-dotnet-app.dll"]Step 4: Build and Run
# Build the Docker imagedocker build -t my-dotnet-app:latest . # Run locallydocker run --rm -p 8080:8080 my-dotnet-app:latest # Test the applicationcurl http://localhost:8080/curl http://localhost:8080/healthMulti-Stage Builds with ReadyToRun
.NET 6+ supports ReadyToRun compilation for faster startup in containers:
# Build stageFROM cleanstart/dotnet:8.0 AS builder WORKDIR /appCOPY *.csproj .RUN dotnet restore COPY . .RUN dotnet publish -c Release -o ./out --self-contained false # Production stageFROM cleanstart/dotnet:8.0 WORKDIR /appRUN useradd -m -u 1000 appuser # Copy published applicationCOPY --from=builder /app/out ./ USER appuserEXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 ENV ASPNETCORE_URLS="http://+:8080"ENV DOTNET_GC_RETAIN_VM=1CMD ["dotnet", "my-dotnet-app.dll"]NuGet Dependency Verification
CleanStart verifies all NuGet packages through the Package Factory. Configure your nuget.config to leverage verified feeds:
<?xml version="1.0" encoding="utf-8"?><configuration> <packageSources> <add key="cleanstart-verified" value="https://feeds.cleanstart.io/nuget/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> </packageSources> <packageSourceCredentials> <cleanstart-verified> <add key="Username" value="readonly" /> <add key="ClearTextPassword" value="$CLEANSTART_NUGET_TOKEN" /> </cleanstart-verified> </packageSourceCredentials></configuration>In your Dockerfile build stage, NuGet restore will automatically verify package checksums against the Package Factory manifest. Use dotnet package verify to audit your dependency tree:
RUN dotnet add package Microsoft.AspNetCore.App --version 8.0.0 && \ dotnet package verifyASP.NET Core Configuration
Kestrel Server Hardening
Configure Kestrel for secure, container-native operation:
var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseKestrel(options =>{ options.Listen(System.Net.IPAddress.Any, 8080); options.AddServerHeader = false;}); builder.Services.ConfigureHttpJsonOptions(options =>{ options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;}); var app = builder.Build();app.Run();Health Checks
Implement comprehensive health checks for Kubernetes and orchestrators:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks() .AddCheck("database", async () => { try { // Example: await dbContext.Database.OpenConnectionAsync(); return HealthCheckResult.Healthy(); } catch (Exception ex) { return HealthCheckResult.Unhealthy(ex.Message); } }); var app = builder.Build(); app.MapHealthChecks("/health");app.MapHealthChecks("/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions{ Predicate = r => r.Name == "database"}); app.Run();Environment-Based Configuration
Externalize settings using environment variables and appsettings files:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true } }, "Kestrel": { "Endpoints": { "Http": { "Url": "http://+:8080" } } }}In your Dockerfile, override settings with environment variables:
ENV ASPNETCORE_ENVIRONMENT=ProductionENV ASPNETCORE_URLS=http://+:8080ENV Logging__LogLevel__Default=InformationFIPS 140-3 Compliance for .NET Crypto
For regulated environments, use CleanStart's FIPS-enabled .NET images and configure cryptography compliance:
FROM cleanstart/dotnet:8.0-fips WORKDIR /appCOPY . .RUN dotnet publish -c Release -o ./outIn your application, ensure FIPS-compliant cryptography:
using System.Security.Cryptography; // Use FIPS-approved algorithmsvar sha256 = SHA256.Create(); // FIPS-approved // Avoid non-FIPS algorithms like MD5 or DES// var md5 = MD5.Create(); // Non-FIPS, will throw in FIPS mode // Configure explicit FIPS settingsCryptoConfig.AddOID("2.16.840.1.101.3.4.2.1", typeof(SHA256));Verify FIPS mode is enabled in your container:
docker run --rm cleanstart/dotnet:8.0-fips \ dotnet -c "using System.Security.Cryptography; Console.WriteLine($'FIPS: {CryptoConfig.AllowOnlyFipsAlgorithms}');"Scanning and Signing Images
After building your .NET image, scan it for vulnerabilities and sign it for supply chain security:
# Build and tag your imagedocker build -t myregistry/my-dotnet-app:1.0 . # Scan with CleanStart scannercleanimg-scan myregistry/my-dotnet-app:1.0 # Sign with Cosigncosign sign --key cosign.key myregistry/my-dotnet-app:1.0 # Push to Image Vaultdocker push myregistry/my-dotnet-app:1.0 # Verify signature before pullingcosign verify --key cosign.pub myregistry/my-dotnet-app:1.0Deploying to Kubernetes
Create a complete Kubernetes deployment with CleanStart hardening:
apiVersion: apps/v1kind: Deploymentmetadata: name: dotnet-app namespace: defaultspec: replicas: 3 selector: matchLabels: app: dotnet-app template: metadata: labels: app: dotnet-app spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: myregistry/my-dotnet-app:1.0 imagePullPolicy: IfNotPresent ports: - containerPort: 8080 name: http protocol: TCP env: - name: ASPNETCORE_ENVIRONMENT value: "Production" - name: ASPNETCORE_URLS value: "http://+:8080" - name: LOG_LEVEL value: "Information" securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 capabilities: drop: - ALL livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 2 resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1000m memory: 512Mi volumeMounts: - name: tmp mountPath: /tmp - name: app-temp mountPath: /home/appuser/.cache volumes: - name: tmp emptyDir: {} - name: app-temp emptyDir: {}---apiVersion: v1kind: Servicemetadata: name: dotnet-appspec: type: ClusterIP selector: app: dotnet-app ports: - port: 80 targetPort: 8080 protocol: TCPTesting Your .NET Application
Add xUnit testing to your project:
dotnet add package xunitdotnet add package xunit.runner.visualstudio --version 2.5.0Create ProgramTests.cs:
using Xunit; public class ProgramTests{ [Fact] public async Task HealthEndpointReturnsOk() { // Arrange & Act using var client = new HttpClient(); var response = await client.GetAsync("http://localhost:8080/health"); // Assert Assert.True(response.IsSuccessStatusCode); }}Run tests in Docker:
FROM cleanstart/dotnet:8.0 AS test WORKDIR /appCOPY . .RUN dotnet restoreRUN dotnet testImage Options
CleanStart provides .NET variants for different needs:
Image | Use Case |
|---|---|
| Latest LTS, recommended for production |
| Previous LTS, stable |
| FIPS 140-3 compliance required |
| Ultra-minimal, distroless variant |
Performance Tips
ReadyToRun compilation improves startup by 30-50 percent. Layer caching works best when you structure Dockerfile with project files before source code. Trimming using <PublishTrimmed>true</PublishTrimmed> reduces image size. Native AOT compiles to native code for sub-second startup (experimental). GC tuning via DOTNET_GCHeapAffinitizeMask helps NUMA systems.
Troubleshooting
Build Issues
Problem: NuGet restore fails with "Certificate validation failure" during docker build Cause: Corporate proxy or MITM inspection; missing nuget.config; network connectivity issues in container Fix: In Dockerfile, disable strict SSL temporarily: RUN dotnet nuget disable source nuget.org && dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org (or use corporate proxy). For proxy environments, configure nuget.config with proxy settings: <config><add key="http_proxy" value="..." />. Verify connectivity: docker run cleanstart/dotnet:8.0 curl https://api.nuget.org/v3-flatcontainer/index.json | head -c 100.
Problem: "Unable to resolve project dependencies" or package version conflicts Cause: Incompatible package versions; transitive dependency conflicts; missing package source Fix: Use dotnet package tree to visualize dependency graph and identify conflicts. Explicitly pin versions in .csproj. Run dotnet nuget verify to check package integrity. Ensure nuget.config lists all required sources. For monorepos, use central version management: create Directory.Packages.props with version definitions.
Problem: Docker layer caching fails; .NET project file changes trigger full rebuild Cause: Source files copied before running restore; change detection too aggressive Fix: Restructure Dockerfile: copy only .csproj and .csproj.user first, restore dependencies, then copy source. This way dependency layer caches when source changes: COPY *.csproj . && RUN dotnet restore && COPY . . && RUN dotnet build.
Problem: Build takes 10+ minutes even for small projects Cause: NuGet downloading packages from slow mirrors; build configuration set to Debug; antivirus scanning during build Fix: Use dotnet publish -c Release instead of Debug. Cache NuGet packages in Docker: docker buildx build --cache-from type=registry,ref=myregistry/my-dotnet-app:buildcache. Or mount build cache: RUN --mount=type=cache,target=/root/.nuget dotnet restore. Disable antivirus scanning of /tmp during builds.
Runtime Issues
Problem: Application exits with "HostAbortedException" or "ThreadAbortException" Cause: SIGTERM signal not handled; no graceful shutdown; process killed before cleanup Fix: .NET handles SIGTERM by default in 5+, but ensure proper shutdown hooks. Implement graceful shutdown: register IHostApplicationLifetime service and listen to ApplicationStopping. Increase Docker stop timeout: docker stop --time=30 container-id for cleanup time. Test: docker kill -s TERM container-id should show shutdown logs.
Problem: "Could not load file or assembly" or missing dependency at runtime Cause: Dependency not copied from build stage; transitive dependency missing; assembly binding issue Fix: Verify all published files are copied: COPY --from=builder /app/out ./. Check file permissions: ls -la in container. For binding redirects, ensure app.config or runtimeconfig.json exists. Run dotnet list package --outdated to audit versions. Verify assembly version matches: dotnet --list-runtimes.
Problem: Port binding fails with "Address already in use" Cause: Kestrel bound to localhost instead of 0.0.0.0; port conflict from previous container Fix: Ensure Kestrel listens on all interfaces: webHost.UseKestrel(options => options.Listen(IPAddress.Any, 8080)) or use ASPNETCORE_URLS=http://+:8080. Verify exposure in Dockerfile: EXPOSE 8080. Check for orphaned containers: docker ps -a | grep my-dotnet-app. Clean up: docker container prune.
Problem: Memory limit exceeded with "OutOfMemoryException" Cause: Container memory too small for .NET runtime; large object heap accumulation; memory leak Fix: Increase container memory: docker run -m 1g or set Kubernetes limits.memory: 1Gi. For .NET tuning, set DOTNET_GC_RETAIN_VM=0 to reduce GC overhead. Monitor memory: docker stats while running. If leak suspected, capture heap dump: dotnet-dump collect -p <pid> and analyze locally with dotnet-dump analyze dump.dmp.
Problem: HEALTHCHECK or readiness probe fails intermittently Cause: Startup not complete when probe runs; timeout too short; endpoint not responding Fix: Increase initialDelaySeconds in Kubernetes (e.g., 30 seconds) to allow startup. Extend healthcheck startup period in Docker: HEALTHCHECK --start-period=30s. Verify endpoint is responsive: docker run --rm my-app curl -v http://localhost:8080/health (from within container). For slow startups, use a startup probe in Kubernetes.
Problem: FIPS mode enabled but application uses non-FIPS algorithms; "System.Security.Cryptography.CryptographicException" Cause: Application or dependency uses MD5, DES, or other non-FIPS algorithms; FIPS enforcement too strict Fix: Audit dependencies with dotnet package tree for crypto usage. Replace non-FIPS algorithms: MD5 → SHA256, DES → AES. Disable FIPS if not required: set FIPS_MODE=0 environment variable (not recommended for compliance). Test in FIPS image first: docker run -it cleanstart/dotnet:8.0-fips bash and run your app code snippet.
Problem: Application crashes on startup with "native library failed to load" or segmentation fault Cause: Managed-to-native interop issue; P/Invoke of unavailable library; incompatible platform architecture Fix: Ensure P/Invoke declarations are correct: [DllImport("mylib.so")]. For Linux, verify library exists in container: docker run --rm myimage ldd /app/mylib.so. Use RID (Runtime Identifier) correctly in .csproj: <RuntimeIdentifier>linux-x64</RuntimeIdentifier>. Check for platform mismatch: app compiled for x64 but running on ARM64. Rebuild specifying target: dotnet publish -c Release --self-contained -r linux-x64.
Performance Issues
Problem: Application startup takes 30+ seconds Cause: JIT compilation of large assemblies; dependency loading overhead; NGEN not applied Fix: Enable ReadyToRun: <PublishReadyToRun>true</PublishReadyToRun> in .csproj. Use tiered compilation: DOTNET_TieredCompilation=1. For critical paths, use Crossgen2: dotnet publish -c Release /p:PublishReadyToRun=true. Profile startup: use ETW or PerfCollect locally to measure JIT time. Consider Native AOT for sub-second startup: <PublishAot>true</PublishAot> (requires .NET 7+).
Problem: High CPU usage with low throughput; application seems to "stall" Cause: GC pauses during full collection; excessive JIT compilation; thread contention Fix: Profile with PerfCollect: dotnet-trace collect -p <pid> and analyze with PerfView. Tune GC: set DOTNET_GCHardLimit=80 to trigger collections before memory exhaustion. Adjust DOTNET_GCHeapCount for multi-core: typically (logical cores / 2). For latency-critical apps, use low-latency mode: DOTNET_GCLatencyMode=Batch or SustainedLowLatency.
Problem: Deployment to staging slower than expected; image pull time excessive Cause: Image size bloated with build artifacts; multiple layers not optimized; registry network latency Fix: Use trimming: <PublishTrimmed>true</PublishTrimmed> to remove unused code. Minimize layers in Dockerfile: combine RUN commands with &&. Push to Image Vault closer to deployment region. Use buildkit with caching: docker buildx build --cache-from type=registry. Check image size: docker inspect my-app:latest --format='{{.Size}}'.
CleanStart Production Hardening
Read-Only Root Filesystem
Enforce immutable infrastructure with read-only root and controlled writable paths:
apiVersion: v1kind: Podmetadata: name: dotnet-appspec: containers: - name: app image: cleanstart/dotnet:8.0 securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: app-config mountPath: /app/config volumes: - name: tmp emptyDir: {} - name: app-config emptyDir: {}Shell-Less ENTRYPOINT
Remove shell from the container for reduced attack surface. Update your Dockerfile:
FROM cleanstart/dotnet:8.0 WORKDIR /appCOPY . .RUN dotnet publish -c Release -o ./out ENTRYPOINT ["/cleanimg-init", "--"]CMD ["dotnet", "out/my-dotnet-app.dll"]cleanimg-customize: Custom .NET Configuration
Inject production settings and security hardening:
FROM cleanstart/dotnet:8.0 WORKDIR /appCOPY . .RUN dotnet publish -c Release -o ./out # Configure runtime hardeningENV DOTNET_TieredCompilation=1ENV DOTNET_GCHeapAffinitizeMask=0ENV ASPNETCORE_ENVIRONMENT=ProductionENV ASPNETCORE_URLS=http://+:8080 ENTRYPOINT ["/cleanimg-init", "--"]CMD ["dotnet", "out/my-dotnet-app.dll"]Security Context
Complete Kubernetes securityContext for hardened .NET containers:
apiVersion: apps/v1kind: Deploymentmetadata: name: dotnet-appspec: template: spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: cleanstart/dotnet:8.0 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 capabilities: drop: - ALL resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1000m memory: 512MiNext Steps
Explore Advanced .NET Deployment. Learn Kubernetes Deployment for .NET. Review CleanStart Image Registry.
