Kubernetes for .NET Developers: From Docker to Production
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
.csprojfiles first meansdotnet restoreonly 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.
Comments
Ajit Gangurde
Software Engineer II at Microsoft | 15+ years in .NET & Azure