dotnet
ASP.NET Core health checks and Kubernetes probes
When you run an ASP.NET Core service inside Kubernetes, two things make rolling deployments and self-healing work correctly: a liveness probe (Kubernetes restarts the pod when this fails) and a readiness probe (Kubernetes stops routing traffic to the pod until this passes). ASP.NET Core has a built-in health check system that maps cleanly onto both.
This post walks through adding health checks to a .NET 8 web API and wiring them to a Kubernetes deployment spec.
Adding the health check middleware
The health check middleware ships with ASP.NET Core — no extra NuGet package is needed for the basics. Add it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
var app = builder.Build();
app.MapHealthChecks("/healthz/live");
app.MapHealthChecks("/healthz/ready");
app.Run();Both endpoints now return 200 OK with the body Healthy. Verify locally:
curl http://localhost:5000/healthz/live
# HealthyChecking real dependencies
A liveness probe only needs to know whether the app process is stuck — a simple self-check is fine. A readiness probe should verify that the app can actually serve traffic, which means its external dependencies are up.
Install community check packages:
dotnet add package AspNetCore.HealthChecks.NpgsqlProvider
dotnet add package AspNetCore.HealthChecks.RedisThen register the checks with a "ready" tag so you can filter them per endpoint:
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy())
.AddNpgsql(connectionString, name: "postgres", tags: new[] { "ready" })
.AddRedis(redisConnectionString, name: "redis", tags: new[] { "ready" });Filter by tag when mapping the endpoints:
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
Predicate = check => check.Name == "self"
});
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});Now /healthz/live only runs the self-check, and /healthz/ready runs the database and Redis checks. A pod will not receive traffic until both external services respond successfully.
Writing a custom health check
For anything not covered by a community package, implement IHealthCheck:
public class PaymentsApiHealthCheck : IHealthCheck
{
private readonly HttpClient _http;
public PaymentsApiHealthCheck(IHttpClientFactory factory)
{
_http = factory.CreateClient("payments");
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync("/ping", cancellationToken);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded($"Payments API returned {(int)response.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Payments API unreachable", ex);
}
}
}Register it alongside the other checks:
builder.Services.AddHealthChecks()
.AddCheck<PaymentsApiHealthCheck>("payments-api", tags: new[] { "ready" });Use Degraded when the service is impaired but not completely down. Kubernetes treats Degraded the same as Healthy (the endpoint still returns 200), but you can surface it in monitoring dashboards through the JSON response format described next.
Returning JSON details
The default response body is plain text. Switch to JSON for structured output that works with dashboards and alerting:
dotnet add package AspNetCore.HealthChecks.UI.Clientapp.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});The response now looks like:
{
"status": "Healthy",
"totalDuration": "00:00:00.0231456",
"entries": {
"postgres": { "status": "Healthy", "duration": "00:00:00.0210000" },
"redis": { "status": "Healthy", "duration": "00:00:00.0021456" }
}
}Wiring to Kubernetes
Add liveness and readiness probes to your deployment spec:
containers:
- name: api
image: myregistry/my-api:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3Key fields:
initialDelaySeconds— how long Kubernetes waits after the container starts before running the first probe. Set this high enough to cover app startup (database migrations, cache warming, etc.).periodSeconds— how often the probe runs.failureThreshold— how many consecutive failures before Kubernetes acts: restarts the pod for liveness, stops routing traffic for readiness.
For a .NET app that runs EF Core migrations on startup, initialDelaySeconds: 30 is a safe floor on large schemas — migrations can take 10–20 seconds.
Startup probe for slow cold starts
If your app takes a long time to start but should recover quickly once running, add a startupProbe alongside the liveness probe:
startupProbe:
httpGet:
path: /healthz/live
port: 8080
failureThreshold: 30
periodSeconds: 5Kubernetes runs the startup probe until it succeeds (up to 150 seconds here: 30 × 5), then hands off to the liveness probe. This lets you keep a tight failureThreshold on the liveness probe without causing false restarts during slow cold starts.
Keeping check timeouts shorter than probe timeouts
ASP.NET Core runs all checks for an endpoint in parallel. Set a per-check timeout so a slow dependency does not block the entire probe response:
builder.Services.AddHealthChecks()
.AddNpgsql(connectionString, name: "postgres", tags: new[] { "ready" },
timeout: TimeSpan.FromSeconds(3));Keep this internal timeout shorter than Kubernetes's timeoutSeconds field (default: 1 second, usually worth raising to 5). That way the check has time to return a meaningful result before Kubernetes considers the HTTP call itself timed out.
Summary
- Map
/healthz/liveto a self-check only; map/healthz/readyto dependency checks. - Tag checks so each endpoint runs only what it needs to.
- Return the JSON response format when you want dashboards or alerting to consume health data.
- Set
initialDelaySecondshigh enough to cover app startup, and add astartupProbefor services with slow cold-start behaviour. - Keep per-check
timeoutvalues shorter than the Kubernetes probetimeoutSeconds.