.NET Microservices with Docker and Kubernetes — A Practical Guide for 2026
The decision to containerise a .NET application used to feel like a significant architectural commitment. In 2026, it is baseline infrastructure. Docker is the standard packaging format for .NET services across every major cloud provider, and Kubernetes has become the default orchestration layer for teams running more than a handful of services. Microsoft's own container images by Microsoft are updated with every .NET release, optimised for production use, and support multi-arch builds out of the box.
What hasn't become simpler is the decision of when to use Kubernetes versus when Docker Compose is the right tool for the job — and how to structure your .NET services so the container boundary is an asset rather than a constraint. This guide covers the practical mechanics with real code, and the decision points that determine whether Kubernetes earns its complexity.
The Right Dockerfile for .NET 10 in 2026
The multi-stage Dockerfile is the non-negotiable starting point. Using the SDK image for the entire container is a mistake that inflates image sizes from ~200MB to over 800MB and ships build tooling into production. Here is the production-ready pattern for a .NET 10 ASP.NET Core service:
# Stage 1: build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["OrderService/OrderService.csproj", "OrderService/"]
RUN dotnet restore "OrderService/OrderService.csproj"
COPY . .
WORKDIR "/src/OrderService"
RUN dotnet publish "OrderService.csproj" \
-c Release \
-o /app/publish \
--no-restore \
-p:PublishReadyToRun=true
# Stage 2: runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
# Run as non-root user — security requirement for most K8s clusters
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
USER appuser
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "OrderService.dll"]
Three things worth calling out here. First, -p:PublishReadyToRun=true eliminates JIT compilation at startup — critical for Kubernetes readiness probes where a slow cold start causes pods to fail their health check and get killed before serving any traffic. Second, running as a non-root user is a security baseline that most enterprise Kubernetes policies enforce — building it into the image means you are not scrambling to fix it at deployment time. Third, the final image is the aspnet runtime, not the sdk — this is the difference between a ~220MB image and an 850MB one.
Docker Compose for Local Development
Before Kubernetes, Docker Compose. Every .NET microservices project should have a docker-compose.yml that brings up the full local environment with a single command. Here is a minimal example for an Order service with a PostgreSQL dependency:
version: '3.9'
services:
orderservice:
build:
context: .
dockerfile: OrderService/Dockerfile
ports:
- "5001:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__Default=Host=db;Database=orders;Username=postgres;Password=secret
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
The condition: service_healthy on the database dependency means the .NET service will not attempt to start until PostgreSQL is actually ready to accept connections — eliminating the race condition that causes startup failures when the app tries to run EF Core migrations against an unavailable database.
Kubernetes Manifests: Deployment and Service
When you need Kubernetes — typically when you pass 15-20 microservices or need multi-node scaling — the minimal production manifest for a .NET service looks like this:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: orderservice
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: orderservice
template:
metadata:
labels:
app: orderservice
spec:
containers:
- name: orderservice
image: myregistry.azurecr.io/orderservice:1.4.2
ports:
- containerPort: 8080
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: orderservice-secrets
key: db-connection-string
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: orderservice
namespace: production
spec:
selector:
app: orderservice
ports:
- port: 80
targetPort: 8080
type: ClusterIP
Two things require specific attention here. First, resources.requests and resources.limits are mandatory in production clusters — without them, a runaway service can consume all node memory and trigger OOMKill cascades across unrelated pods. Set requests to what you actually need and limits to a reasonable ceiling, not to the same value. Second, separate liveness and readiness probes are not optional: the liveness probe should return healthy as long as the process is running and not deadlocked; the readiness probe should check actual dependencies — database connectivity, downstream service availability — and return unhealthy when the service cannot handle traffic. Kubernetes will restart a pod that fails liveness, and route traffic away from a pod that fails readiness. These are different operations serving different failure modes.
Health Checks in ASP.NET Core
The probes above need corresponding endpoints. ASP.NET Core has first-class health check support since .NET Core 2.2:
// Program.cs
builder.Services.AddHealthChecks()
.AddNpgsql(
connectionString: builder.Configuration.GetConnectionString("Default")!,
name: "database",
tags: ["ready"])
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]);
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
The tags separation ensures the liveness endpoint only checks whether the application process is healthy — no database call required — while the readiness endpoint verifies that all dependencies are reachable. In a Kubernetes rolling deployment, this means pods are only added to the load balancer rotation after their database connection is confirmed healthy.
The Decision Framework: Docker Compose vs Kubernetes
The 2026 consensus from production deployments is clear:
Use Docker Compose when: you have fewer than 15 microservices, you are a team of 1-10 engineers, your monthly compute spend is under $10,000, or you are in early-stage product development. Docker Compose handles most applications until significant scale. The operational overhead of Kubernetes — learning its model, maintaining its configuration, managing certificates and RBAC and namespaces — is real and it compounds in small teams.
Migrate to Kubernetes when: you hit 20+ microservices, you need multi-region deployment or zero-downtime rolling updates at scale, traffic spikes cause availability issues that horizontal pod autoscaling would address, or compliance requirements demand infrastructure audit logging and network policy enforcement.
The break-even point where Kubernetes' efficiency gains exceed its operational cost typically occurs around 15-20 microservices or $10,000-$15,000 per month in compute spend. At that scale, Kubernetes' bin-packing of pods onto nodes and its auto-scaling reduce total infrastructure cost by 30-40% compared to statically provisioned Docker hosts.
For .NET specifically: the mcr.microsoft.com/dotnet/aspnet:10.0 base image is 220MB. With PublishReadyToRun=true compilation, a typical ASP.NET Core service starts in under 500ms in a Kubernetes pod — well within the default initialDelaySeconds: 10 window on the readiness probe. Native AOT compilation, available for specific workloads in .NET 10, can reduce this to under 50ms and shrink the container image below 30MB.
Observability: The Part People Forget Until Production
A containerised .NET service without observability is a service you cannot debug in production. The minimum baseline in 2026 is structured logging with Serilog or Microsoft.Extensions.Logging configured for JSON output — because Kubernetes aggregates logs from stdout and JSON output makes them queryable:
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
options.IncludeScopes = true;
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ssZ";
});
Add the OpenTelemetry packages for distributed tracing — OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore, OpenTelemetry.Exporter.Otlp — and configure them to export to your observability backend (Grafana, Datadog, Azure Monitor). In a microservices architecture, tracing is the difference between debugging a latency issue in 20 minutes and spending two days adding log statements.
The container layer, the orchestration layer, and the observability layer are each a day's work to set up correctly. That investment pays for itself the first time something goes wrong in production at 2am.


