Deploying a .NET API to Kubernetes without the headache
A minimal Dockerfile, a clean deployment manifest, and the health checks and limits that keep an ASP.NET Core service stable.
Kubernetes has a reputation for being complicated, and a lot of that reputation is earned. But deploying a straightforward ASP.NET Core API onto it is not the hard part, as long as you get a handful of things right: a lean image, honest health checks, and sensible resource limits. Miss those and you get restart loops and mysterious evictions. Get them right and the service just runs.
Here's the setup I use.
A multi-stage Dockerfile
The mistake I see most is shipping the whole SDK in the runtime image, which balloons it to over a gigabyte. Use a multi-stage build so the final image contains only the runtime and your compiled app.
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Copying the .csproj and restoring before copying the rest means Docker caches the restore layer. Change your code and the restore doesn't rerun, so rebuilds are fast. The final image is the slim aspnet runtime, a couple hundred megabytes instead of over a gigabyte, which pulls faster and gives an attacker less to work with.
Health checks the cluster can trust
Kubernetes needs two questions answered: is this container alive, and is it ready for traffic. ASP.NET Core has built-in support for both.
builder.Services.AddHealthChecks()
.AddNpgSql(conn, name: "database");
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // liveness: is the process up at all
});
app.MapHealthChecks("/health/ready"); // readiness: are dependencies ok
The distinction matters more than it looks. Liveness should be cheap and check nothing but the process itself, because if it checks the database and the database blips, Kubernetes kills a perfectly healthy pod and makes the outage worse. Readiness is where you check dependencies, so a pod that can't reach the database is pulled from the load balancer without being restarted. Conflating the two is the single most common cause of self-inflicted restart storms.
The deployment manifest
The manifest ties it together: which image, how many replicas, and crucially, the probes and limits.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapi
spec:
replicas: 3
selector:
matchLabels: { app: myapi }
template:
metadata:
labels: { app: myapi }
spec:
containers:
- name: myapi
image: registry/myapi:1.4.0
ports:
- containerPort: 8080
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
initialDelaySeconds: 10
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
Why the limits are not optional
The resources block is the part people skip, and it's the part that keeps the cluster stable. requests tell the scheduler how much the pod needs, so it places pods on nodes that can actually fit them. limits cap what a pod can consume, so one misbehaving container can't starve everything else on the node.
Leave limits off and a memory leak in one service takes down every other pod sharing that node. With a memory limit, the leaky pod alone gets killed and restarted, and the blast radius stays contained. I treat limits as a safety fuse, not an optimization.
Pin the image to a real version tag like 1.4.0, never latest. With latest you have no idea which build is actually running, and a rollback becomes guesswork. This is the same discipline as using dated lastmod values instead of build timestamps, honest signals over convenient ones.
Rolling updates for free
Because there are three replicas and a readiness probe, Kubernetes gives you zero-downtime deploys without extra work. It starts new pods, waits for each to report ready, and only then retires the old ones. If the new version fails its readiness check, the rollout stalls with the old pods still serving, and nobody notices. That safety net only exists if your readiness probe is honest, which loops back to getting the health checks right.
Where to build the image
I don't build these by hand. The image build and push happen in CI, tagged with the commit or a version, then the deploy references that tag. I wrote about wiring that up in .NET CI/CD on Azure. The Dockerfile and manifest here are what that pipeline produces and applies.
None of this is exotic. A slim multi-stage image, a liveness probe that checks only the process, a readiness probe that checks dependencies, and resource limits so one bad pod can't sink the node. That covers the ninety percent case, and the ninety percent case is almost every API you'll deploy.