Learn ASP.NET Core health checks step by step: liveness, readiness, SQL Server, Redis, custom checks, and Kubernetes probes. Monitor your production app today.
Your app returned HTTP 200 on the home page, so everything is fine — right? Not necessarily. The database connection pool could be exhausted, Redis could be unreachable, and a downstream API could be timing out while your load balancer happily keeps sending traffic your way. ASP.NET Core health checks solve exactly this problem: they give orchestrators, load balancers, and monitoring tools a reliable, machine-readable answer to the question "is this instance actually able to serve requests?" In this guide you'll learn how to add health checks to ASP.NET Core, wire up liveness and readiness endpoints, check SQL Server and Redis, write custom health checks in C#, and integrate everything with Kubernetes and Docker.
What Are ASP.NET Core Health Checks?
Health checks are small pieces of code that report the status of your application and its dependencies. ASP.NET Core ships with a built-in framework in the Microsoft.Extensions.Diagnostics.HealthChecks package (included in the shared framework) that exposes an HTTP endpoint returning one of three states:
- Healthy – the app and its dependencies are working normally.
- Degraded – the app works, but something is slow or partially unavailable (for example, a cache is down but the database is fine).
- Unhealthy – the app cannot do its job and should not receive traffic.
By default, Healthy and Degraded return HTTP 200, and Unhealthy returns HTTP 503. That status code is what Kubernetes, Azure App Service, AWS ALB, and Nginx use to decide whether to route traffic to — or restart — your instance.
Why Health Checks Matter in Production
Without health checks, failure detection is reactive: a customer complains, someone looks at logs, and by then you have lost an hour. With health checks, the platform reacts automatically:
- Zero-downtime deployments – a new container only receives traffic once it reports ready.
- Self-healing – a deadlocked process fails its liveness probe and gets restarted.
- Load balancer integration – unhealthy instances are pulled from rotation before users notice.
- Observability – dashboards and alerts are driven by real dependency status, not guesswork.
How to Add Health Checks in ASP.NET Core (Basic Setup)
The minimal setup takes two lines in Program.cs. This example targets .NET 8/9/10 minimal hosting, but the same APIs work in older Startup.cs projects.
var builder = WebApplication.CreateBuilder(args);
// 1. Register the health check services
builder.Services.AddHealthChecks();
var app = builder.Build();
// 2. Expose the endpoint
app.MapHealthChecks("/health");
app.Run();
Run the app and open https://localhost:5001/health. You'll see a plain-text response: Healthy. With no checks registered, the framework simply reports that the process is up and can respond to HTTP — which is already useful as a liveness signal.
Liveness vs. Readiness: The Two Endpoints Every App Needs
This is the concept most tutorials skip, and it's the one that matters most in production. Kubernetes (and most other orchestrators) distinguish two questions:
- Liveness – "Is the process alive, or should I kill and restart it?" This check should be cheap and should not depend on external systems. If your database goes down and your liveness probe fails, Kubernetes will restart every pod in a loop, making a bad situation worse.
- Readiness – "Can this instance handle requests right now?" This check should verify dependencies like the database, cache, and message broker. When it fails, traffic is routed elsewhere but the pod is left alone.
ASP.NET Core supports this separation through tags and the Predicate option:
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
// A trivial check tagged "live" — always healthy if the process runs
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
// Dependency checks tagged "ready"
.AddSqlServer(
builder.Configuration.GetConnectionString("Default")!,
name: "sql-server",
tags: ["ready"])
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis",
failureStatus: HealthStatus.Degraded, // cache down = degraded, not dead
tags: ["ready"]);
var app = builder.Build();
// Liveness: only checks tagged "live"
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
// Readiness: only checks tagged "ready"
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
app.Run();
The AddSqlServer and AddRedis extensions come from the community-maintained AspNetCore.Diagnostics.HealthChecks project. Install them with:
dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.Redis
Notice the failureStatus: HealthStatus.Degraded on Redis. Why? If your app can fall back to the database when the cache is unavailable, a Redis outage shouldn't take the whole instance out of rotation. Choosing the correct failure status per dependency is one of the most important design decisions in a health check strategy.
Returning Detailed JSON from the Health Check Endpoint
The default plain-text response is fine for a load balancer, but engineers debugging an incident want to know which check failed and why. Use ResponseWriter to emit JSON. The AspNetCore.HealthChecks.UI.Client package provides a ready-made writer, but writing your own takes ten lines and avoids the extra dependency:
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var payload = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
error = e.Value.Exception?.Message
})
};
await context.Response.WriteAsync(
JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
}
});
A sample response when Redis is down:
{
"status": "Degraded",
"totalDuration": 41.2,
"checks": [
{ "name": "sql-server", "status": "Healthy", "duration": 12.7, "description": null, "error": null },
{ "name": "redis", "status": "Degraded", "duration": 28.5, "description": null,
"error": "It was not possible to connect to the redis server(s)." }
]
}
Writing a Custom Health Check in C#
The built-in and community packages cover most databases and brokers, but every real application has business-specific conditions: a background queue backing up, a license expiring, a required downstream API being unreachable. Implement IHealthCheck to cover these.
Here is a custom health check that calls an external payments API and reports Degraded when it is slow and Unhealthy when it fails:
using System.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks;
public sealed class PaymentsApiHealthCheck : IHealthCheck
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<PaymentsApiHealthCheck> _logger;
public PaymentsApiHealthCheck(
IHttpClientFactory httpClientFactory,
ILogger<PaymentsApiHealthCheck> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var client = _httpClientFactory.CreateClient("payments");
var stopwatch = Stopwatch.StartNew();
try
{
using var response = await client.GetAsync("/status", cancellationToken);
stopwatch.Stop();
var data = new Dictionary<string, object>
{
["statusCode"] = (int)response.StatusCode,
["responseTimeMs"] = stopwatch.ElapsedMilliseconds
};
if (!response.IsSuccessStatusCode)
{
return HealthCheckResult.Unhealthy(
$"Payments API returned {(int)response.StatusCode}", data: data);
}
if (stopwatch.ElapsedMilliseconds > 1000)
{
return HealthCheckResult.Degraded(
"Payments API is responding slowly", data: data);
}
return HealthCheckResult.Healthy("Payments API is reachable", data);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Payments API health check failed");
// Respect the failure status configured at registration time
return new HealthCheckResult(
context.Registration.FailureStatus,
"Payments API is unreachable",
ex);
}
}
}
Register it with dependency injection. AddCheck<T> resolves the class from the container, so constructor injection just works:
builder.Services.AddHttpClient("payments", client =>
{
client.BaseAddress = new Uri(builder.Configuration["PaymentsApi:BaseUrl"]!);
client.Timeout = TimeSpan.FromSeconds(3);
});
builder.Services.AddHealthChecks()
.AddCheck<PaymentsApiHealthCheck>(
"payments-api",
failureStatus: HealthStatus.Unhealthy,
tags: ["ready"],
timeout: TimeSpan.FromSeconds(5));
Two details worth highlighting. First, the check returns context.Registration.FailureStatus in the catch block instead of hard-coding Unhealthy. That lets the person registering the check decide how severe a failure is, without editing the class. Second, the timeout parameter guarantees the check can't hang forever — a health endpoint that takes 30 seconds to respond is as bad as one that fails.
Checking Entity Framework Core DbContext
If you use EF Core, there is a first-party check that verifies the DbContext can connect:
// dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database", tags: ["ready"]);
This calls Database.CanConnectAsync() under the hood. It does not run migrations or verify schema — it just proves the connection works, which is exactly what a readiness probe should do.
Kubernetes Readiness and Liveness Probes for ASP.NET Core
With the two endpoints in place, the Kubernetes deployment manifest maps directly onto them:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
template:
spec:
containers:
- name: orders-api
image: myregistry/orders-api:1.4.0
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
failureThreshold: 30
periodSeconds: 5
The startupProbe is a useful addition for .NET apps that do heavy work on boot (warming caches, running migrations). Until it succeeds, the liveness probe is disabled, so a slow-starting container isn't killed prematurely.
For plain Docker or Docker Compose, use a HEALTHCHECK instruction in your Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
Health Check UI and Dashboards
For a visual dashboard across many services, the AspNetCore.HealthChecks.UI package polls your endpoints and renders a status page with history. It also supports webhooks to Slack, Teams, or a pager when a service transitions to Unhealthy:
// dotnet add package AspNetCore.HealthChecks.UI
// dotnet add package AspNetCore.HealthChecks.UI.InMemory.Storage
builder.Services
.AddHealthChecksUI(options =>
{
options.AddHealthCheckEndpoint("Orders API", "/health/ready");
options.SetEvaluationTimeInSeconds(30);
})
.AddInMemoryStorage();
app.MapHealthChecksUI(options => options.UIPath = "/health-ui");
For larger systems, prefer scraping the health endpoint into your existing observability stack (Prometheus, Datadog, Azure Monitor, Grafana) rather than running a separate UI — one pane of glass beats five.
Best Practices for ASP.NET Core Health Checks
- Keep liveness checks dependency-free. A liveness probe that hits the database will cause restart storms during a database outage.
- Set timeouts on every check. Use the
timeoutparameter on registration and shortHttpClienttimeouts. Health endpoints must respond in well under a second. - Use Degraded deliberately. Reserve Unhealthy for "cannot serve requests." A slow cache or an optional feature being down is Degraded.
- Secure detailed output. Exception messages and connection details can leak infrastructure information. Either restrict
/health/readyto your internal network (RequireHost, IP allow-lists, orRequireAuthorization()) or return the detailed JSON only from an internal endpoint and plain status publicly. - Exclude health endpoints from logging and auth noise. A probe every 10 seconds across 20 pods is 172,800 log lines per day. Filter them in Serilog or your logging middleware.
- Don't run expensive queries.
SELECT 1is enough to prove connectivity; never run business queries inside a health check. - Cache results for high-frequency polling. If several probes hit the same dependency, consider memoizing the result for a few seconds so the checks themselves don't become load.
Common Pitfalls
- Single
/healthendpoint used for both liveness and readiness. This is the number one mistake. It couples pod restarts to dependency failures. - Health checks running before the app is ready. Forgetting
initialDelaySecondsor a startup probe means Kubernetes may kill a container that is still applying migrations. - Forgetting
failureStatus. The default is Unhealthy for every check, so a cosmetic dependency can take down an instance. - Checking the wrong thing. A check that pings Google to "verify the internet" tells you nothing about whether your SQL Server is reachable.
- Swallowing exceptions in custom checks. Always return a result with the exception attached; otherwise the JSON output shows Unhealthy with no explanation.
- Publishing health checks over HTTPS only in containers. Probes usually run over plain HTTP inside the cluster. Make sure Kestrel listens on an HTTP port (
ASPNETCORE_HTTP_PORTS=8080) or the probes will fail.
Conclusion
ASP.NET Core health checks turn "is my app up?" from a guess into a measurable, automated signal. Once you split liveness from readiness, tag your checks, choose the right failure status per dependency, and return structured JSON, your orchestrator and load balancer can handle failures faster than any human on call. Here are the key takeaways:
- Register with
AddHealthChecks()and expose withMapHealthChecks()— it takes two lines to get started. - Always expose separate
/health/liveand/health/readyendpoints using tags and aPredicate. - Use community packages for SQL Server, Redis, RabbitMQ, and more; implement
IHealthCheckfor business-specific conditions. - Prefer
DegradedoverUnhealthyfor optional dependencies, and set timeouts on everything. - Map the endpoints to Kubernetes liveness, readiness, and startup probes, and feed the results into your monitoring stack.
Add these health checks to your ASP.NET Core application today, and the next time a dependency fails at 3 a.m., your platform will route around it before your pager ever goes off.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment