Skip to main content

Kubernetes for .NET Developers: From Docker to Production

March 28, 2026 7 min read

I remember the first time I deployed a .NET application to Kubernetes. I had a perfectly working Docker container, a vague understanding of pods and services, and the naive confidence that "it can't be that different from App Service." Three days and about forty failed deployments later, I had a healthy respect for the platform and a long list of lessons learned the hard way.

Kubernetes isn't inherently complicated for .NET developers, but there's a translation gap between what you know about ASP.NET Core and what Kubernetes expects from your application. Health checks need to map to liveness and readiness probes. Configuration needs to come from ConfigMaps and Secrets instead of appsettings.json. Graceful shutdown needs to actually be graceful. These aren't hard problems, but they're easy to get wrong if nobody tells you about them upfront.

This post covers the full journey from Dockerfile to production on AKS. I'll share the patterns that have worked across a dozen production deployments and the mistakes I've seen (and made) along the way.

Dockerfile Best Practices for .NET

Your Dockerfile is the foundation. A bad Dockerfile means slow builds, bloated images, and potential security vulnerabilities. Here's the pattern I use for every .NET API:

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
WORKDIR /src

# Copy csproj files first for better layer caching
COPY ["src/OrderApi/OrderApi.csproj", "src/OrderApi/"]
COPY ["src/OrderApi.Domain/OrderApi.Domain.csproj", "src/OrderApi.Domain/"]
COPY ["src/OrderApi.Infrastructure/OrderApi.Infrastructure.csproj", "src/OrderApi.Infrastructure/"]
RUN dotnet restore "src/OrderApi/OrderApi.csproj"

# Copy everything else and build
COPY . .
WORKDIR "/src/src/OrderApi"
RUN dotnet publish -c Release -o /app/publish \
    --no-restore \
    /p:UseAppHost=false

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS runtime
WORKDIR /app

# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "OrderApi.dll"]

Key decisions here:

  • Alpine base images — cuts your image size from ~210MB to ~85MB. I've seen teams skip this and end up with 500MB+ images when they have multiple projects.
  • Multi-stage build — the SDK is only in the build stage. Your runtime image doesn't carry the compiler.
  • Non-root user — Kubernetes clusters with PodSecurityPolicies or PodSecurityStandards will reject containers running as root. Set this up in your Dockerfile, not as an afterthought.
  • Layer caching — copying .csproj files first means dotnet restore only reruns when dependencies change, not on every code change.

One thing that catches people: ASP.NET Core 8+ defaults to port 8080 instead of 80. Make sure your Kubernetes service and health probes target the right port.

Deployment YAML: The Essentials

Here's a production-ready deployment manifest that covers the patterns I've seen work best:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api
  labels:
    app: order-api
    version: "1.0"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-api
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0  # Zero downtime deployments
  template:
    metadata:
      labels:
        app: order-api
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: order-api
          image: myregistry.azurecr.io/order-api:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: ConnectionStrings__DefaultConnection
              valueFrom:
                secretKeyRef:
                  name: order-api-secrets
                  key: db-connection-string
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:
            httpGet:
              path: /alive
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /alive
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 5
            failureThreshold: 12  # 60 seconds to start up

The critical detail most guides skip: use maxUnavailable: 0 for zero-downtime deployments. Combined with proper readiness probes, this ensures Kubernetes never routes traffic to a pod that isn't ready.

Health Probes: Getting Them Right

Kubernetes probes map directly to ASP.NET Core health checks, but you need to think carefully about what each probe should test:

  • Liveness probe (/alive) — "Is this process healthy?" Only check that the app itself is running. Don't check database connectivity here — if your database goes down, you don't want Kubernetes restarting all your pods.
  • Readiness probe (/health) — "Can this pod serve traffic?" Check all dependencies: database connections, cache, external services.
  • Startup probe — "Has this pod finished starting up?" Prevents liveness probes from killing slow-starting apps.
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddNpgSql(
        builder.Configuration.GetConnectionString("DefaultConnection")!,
        name: "database",
        tags: ["ready"])
    .AddRedis(
        builder.Configuration.GetConnectionString("Redis")!,
        name: "cache",
        tags: ["ready"]);

var app = builder.Build();

// Liveness — only checks tagged "live"
app.MapHealthChecks("/alive", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

// Readiness — checks all dependencies
app.MapHealthChecks("/health", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready") || check.Tags.Contains("live")
});

I've seen teams make the mistake of checking database health in the liveness probe. When the database has a brief hiccup, Kubernetes restarts every pod simultaneously, turning a minor issue into a full outage. Keep liveness probes simple.

Helm Charts for .NET Services

Once you're past the "single YAML file" stage, Helm charts keep your deployments manageable. Here's a minimal chart structure that works for most .NET APIs:

charts/order-api/
├── Chart.yaml
├── values.yaml
├── values-staging.yaml
├── values-production.yaml
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── hpa.yaml
    ├── configmap.yaml
    └── secret.yaml

Your values.yaml becomes the single source of truth for environment-specific configuration:

# values.yaml (defaults)
replicaCount: 2
image:
  repository: myregistry.azurecr.io/order-api
  tag: "latest"
  pullPolicy: IfNotPresent

resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

env:
  ASPNETCORE_ENVIRONMENT: Production
  DOTNET_gcServer: "1"
# values-production.yaml (overrides)
replicaCount: 3

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: "1Gi"

autoscaling:
  minReplicas: 3
  maxReplicas: 20

Deploy with: helm upgrade --install order-api ./charts/order-api -f values-production.yaml

Horizontal Pod Autoscaling and Secrets

The HPA is your safety net for traffic spikes. Here's a configuration that combines CPU and memory metrics with custom metrics from Prometheus:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-api
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # Wait 5 min before scaling down
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

The behavior section is crucial. Without it, the HPA will aggressively scale down after a traffic spike, potentially causing issues when the next wave hits. I set a 5-minute stabilization window for scale-down — this has prevented flapping in every deployment I've managed.

For secrets, use Azure Key Vault with the CSI Secrets Store Driver on AKS:

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: order-api-secrets
spec:
  provider: azure
  parameters:
    usePodIdentity: "false"
    useVMManagedIdentity: "true"
    userAssignedIdentityID: "<managed-identity-client-id>"
    keyvaultName: "myapp-keyvault"
    objects: |
      array:
        - |
          objectName: db-connection-string
          objectType: secret
        - |
          objectName: redis-connection-string
          objectType: secret
    tenantId: "<tenant-id>"
  secretObjects:
    - secretName: order-api-secrets
      type: Opaque
      data:
        - objectName: db-connection-string
          key: db-connection-string

This pulls secrets from Key Vault and mounts them as Kubernetes secrets, which your deployment can reference via secretKeyRef. No secrets in your Git repo, no manual secret creation.

AKS-Specific Tips

After running .NET workloads on AKS for several years, here are the tips I wish someone had given me early on:

Enable workload identity instead of pod identity. It's the current recommended approach and integrates cleanly with managed identities:

// In your .NET app, use DefaultAzureCredential
// It automatically picks up the workload identity token
builder.Services.AddAzureClients(clients =>
{
    clients.UseCredential(new DefaultAzureCredential());
    clients.AddBlobServiceClient(new Uri("https://mystorage.blob.core.windows.net"));
});

Set resource requests accurately. Use kubectl top pods and the Metrics Server to understand actual usage before setting limits. Over-provisioning wastes money; under-provisioning causes OOM kills and CPU throttling.

Configure graceful shutdown in your .NET app to handle SIGTERM properly:

var app = builder.Build();

var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
    // Give in-flight requests time to complete
    Thread.Sleep(TimeSpan.FromSeconds(10));
});

app.Run();

Conclusion

Kubernetes for .NET isn't about learning an entirely new paradigm — it's about mapping concepts you already know to Kubernetes primitives. Health checks become probes. Configuration becomes ConfigMaps and Secrets. Scaling becomes HPAs. The mental model transfers well once you understand the mapping.

Start with a solid Dockerfile, get your health probes right, and use Helm charts from day one. These three foundations will save you more debugging time than any other Kubernetes investment. And remember: the goal isn't to become a Kubernetes expert. The goal is to ship reliable .NET applications that scale. Kubernetes is just the platform that makes that possible.

Share this post

Comments

Ajit Gangurde

Software Engineer II at Microsoft | 15+ years in .NET & Azure