Skip to content
.N
Startups // May 20, 2026 · 6 min read

.NET Microservices with Docker and Kubernetes — A Practical Guide for 2026

LM
Liam Mercer
// contributor
.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.

More from Dot Net Masters