Health Checks — Know Your API Is Alive Before Kubernetes Does
Implement liveness, readiness, and startup probes in ASP.NET Core. Built-in DB checks, community NuGet packages, custom IHealthCheck, and Kubernetes probe config.
The Three Probe Types
Kubernetes uses three probes to manage your pod lifecycle:
| Probe | Question | Failure action | |---|---|---| | Liveness | Is the process healthy (not deadlocked)? | Restart the pod | | Readiness | Is the service ready to accept traffic? | Remove from load balancer | | Startup | Has the app finished initialising? | Don't run liveness until this passes |
Map these to separate /health/* endpoints in your API.
Basic Setup
# No extra packages needed for core functionality
dotnet add package AspNetCore.HealthChecks.UI
dotnet add package AspNetCore.HealthChecks.UI.Client
dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.Redis
dotnet add package AspNetCore.HealthChecks.Uris// Program.cs
builder.Services
.AddHealthChecks()
// EF Core database check
.AddDbContextCheck<AppDbContext>("database", tags: ["ready"])
// Direct SQL connection check
.AddSqlServer(
builder.Configuration.GetConnectionString("Default")!,
name: "sql-server",
tags: ["ready"])
// Redis
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis",
tags: ["ready"])
// External URL
.AddUrlGroup(
new Uri("https://api.stripe.com"),
name: "stripe-api",
tags: ["ready"]);
var app = builder.Build();
// Liveness — is the process alive? No dependency checks.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false, // run no checks, just return Healthy if process is up
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// Readiness — are all dependencies reachable?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// Full — everything, for dashboards
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});Custom IHealthCheck
For checks that don't have a pre-built package — a message queue depth, a third-party service, a file system path:
public class OrderQueueHealthCheck(IMessageQueue queue) : IHealthCheck
{
private const int MaxQueueDepth = 10_000;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var depth = await queue.GetDepthAsync(cancellationToken);
var data = new Dictionary<string, object>
{
["queue_depth"] = depth,
["threshold"] = MaxQueueDepth
};
if (depth > MaxQueueDepth)
return HealthCheckResult.Degraded(
$"Queue depth {depth} exceeds threshold {MaxQueueDepth}", data: data);
return HealthCheckResult.Healthy($"Queue depth: {depth}", data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Cannot reach message queue", ex);
}
}
}
// Register it
builder.Services
.AddHealthChecks()
.AddCheck<OrderQueueHealthCheck>("order-queue", tags: ["ready"]);HealthChecks UI Dashboard
// Program.cs — services
builder.Services
.AddHealthChecksUI(options =>
{
options.SetEvaluationTimeInSeconds(30);
options.MaximumHistoryEntriesPerEndpoint(50);
options.AddHealthCheckEndpoint("API", "/health");
})
.AddInMemoryStorage(); // or .AddSqlServerStorage(connStr) for persistence
// Program.cs — middleware
app.MapHealthChecksUI(config => config.UIPath = "/health-ui");Browse to /health-ui for a live dashboard showing status history for every check.
Kubernetes Probe Configuration
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: api
image: myregistry/api:latest
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /health/live
port: 8080
failureThreshold: 30 # 30 * 10s = 5 min to start
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3 # restart after 3 failures
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3 # pull from LB after 3 failuresKey points:
startupProbeprevents liveness killing a slow-starting app (EF Core migrations, cache warm-up)- Liveness uses
/health/live(no deps) — you don't want a Redis blip to restart your pod - Readiness uses
/health/ready(deps checked) — a DB outage should remove the pod from rotation
Securing the Health Endpoint
Health responses can leak connection strings and internal URLs. Restrict access:
app.MapHealthChecks("/health")
.RequireAuthorization("InternalOnly");
// or restrict to cluster-internal IPs only via network policy,
// and return minimal output to the public:
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
// No ResponseWriter — returns plain 200 or 503, no JSON body
});Summary
- Three endpoints:
/health/live(process alive),/health/ready(deps reachable),/health(full detail) - Tag checks with
"ready"and filter by tag so liveness never touches external dependencies - Custom
IHealthCheckcovers anything not in the NuGet ecosystem — queue depth, disk space, license validity - Match Kubernetes probe paths exactly to your mapped endpoints; use
startupProbefor apps with slow cold-start
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.