Skip to main content
CleanStart

Knowledge Hub

Getting Started with .NET on CleanStart

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-app

Create 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: true

This 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/health

Multi-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 verify

ASP.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=Information

FIPS 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 ./out

In 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.0

Deploying 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: TCP

Testing Your .NET Application

Add xUnit testing to your project:

dotnet add package xunitdotnet add package xunit.runner.visualstudio --version 2.5.0

Create 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 test

Image Options

CleanStart provides .NET variants for different needs:

Image

Use Case

cleanstart/dotnet:8.0

Latest LTS, recommended for production

cleanstart/dotnet:7.0

Previous LTS, stable

cleanstart/dotnet:8.0-fips

FIPS 140-3 compliance required

cleanstart/dotnet:8.0-chiseled

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: 512Mi

Next Steps

Explore Advanced .NET Deployment. Learn Kubernetes Deployment for .NET. Review CleanStart Image Registry.