Health Checks in ASP.NET Core: A Beginner's Guide
Learn health checks in ASP.NET Core: add liveness and readiness endpoints, check your database and Redis, write custom checks, and wire up Kubernetes probes.
A doctor's quick check-up
Think about going to a doctor for a routine check-up. The doctor does not run every test in the hospital. They do a few quick things: check your pulse, listen to your heart, look at your breathing. In a minute they can say "you are fine," "you are okay but rest a bit," or "we need to act now."
A health check in ASP.NET Core is exactly this kind of quick check-up, but for your web app. Instead of a doctor, the one doing the check-up is a robot — maybe Kubernetes, a load balancer, or an uptime monitor. It visits a special URL on your app every few seconds and asks one simple question: "Are you okay?"
Your app runs a few fast checks — can it reach the database, is Redis up, is there enough disk space — and replies with one of three answers: Healthy, Degraded, or Unhealthy. The robot then decides what to do: keep sending traffic, slow down, or restart the app.
This guide teaches you how to set this up from zero, in plain steps. By the end you will be able to add liveness and readiness endpoints, check your database, write your own custom check, and connect everything to Kubernetes.
Why health checks matter
Imagine your shop's website is running, but its database connection quietly broke. The website still says "200 OK" on the home page because that page is cached. But every checkout fails. Customers leave. Nobody notices for an hour.
A health check would have caught this in seconds. The robot would have asked "are you okay?", your app would have tried to reach the database, failed, and answered Unhealthy. The load balancer would then stop sending traffic to that broken copy and route people to a healthy one.
The big idea: a health check turns "is my app okay?" from a guess into a clear, machine-readable answer.
The three health states
ASP.NET Core uses three statuses. Learn these and the rest is easy.
| Status | Meaning | Default HTTP code | Real-life example |
|---|---|---|---|
| Healthy | Everything works fine | 200 OK | Database reachable, cache fast |
| Degraded | Works, but not great | 200 OK | Cache slow, app falls back to DB |
| Unhealthy | Cannot do its job | 503 Service Unavailable | Database is unreachable |
Notice that Degraded still returns 200 by default. The app is up, just not at its best. Only Unhealthy returns 503, which tells a load balancer to stop sending traffic.
Your very first health check
Let us add the simplest possible health check. The core API ships with ASP.NET Core, so you do not need any extra NuGet package for this.
Open your Program.cs and add two lines.
var builder = WebApplication.CreateBuilder(args);
// 1) Register the health check service
builder.Services.AddHealthChecks();
var app = builder.Build();
// 2) Expose an endpoint that runs the checks
app.MapHealthChecks("/healthz");
app.Run();That is it. Run the app and open https://localhost:5001/healthz in your browser. You will see the plain text word Healthy. You just built a working health check endpoint.
Right now it does not check anything real — it only proves the app is responding. The next step is to add checks that look at the parts your app depends on.
What happens when a request comes in
Here is the path a health request takes inside your app. The endpoint runs every registered check, gathers the results, picks the worst one, and writes a response.
How a health request is handled
Steps
Request
GET /healthz arrives
Run checks
Each IHealthCheck runs
Combine results
Collect Healthy/Degraded/Unhealthy
Pick worst
Overall = lowest status
Write response
200 or 503 + body
The rule for the overall status is simple: the app is only as healthy as its weakest part. If one check is Unhealthy, the whole endpoint reports Unhealthy.
Adding a real check: the database
A health endpoint that checks nothing is not very useful. The most common real check is "can I reach my database?" For ready-made checks, the community Xabaril project gives you packages for almost every database and service.
For SQL Server, install the package:
dotnet add package AspNetCore.HealthChecks.SqlServerThen register it. You can give each check a friendly name and tags. Tags are labels you use later to group checks.
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: builder.Configuration.GetConnectionString("Default")!,
name: "sql-server",
tags: new[] { "db", "ready" })
.AddRedis(
redisConnectionString: builder.Configuration.GetConnectionString("Redis")!,
name: "redis-cache",
tags: new[] { "cache", "ready" });Now when the robot calls /healthz, your app actually opens the database, runs a tiny query, and pings Redis. If the database is down, the endpoint reports Unhealthy and returns 503. This is the moment health checks start earning their keep.
The Xabaril project covers many systems. Here are a few popular packages.
| What you want to check | NuGet package | Tag idea |
|---|---|---|
| SQL Server | AspNetCore.HealthChecks.SqlServer | db |
| PostgreSQL | AspNetCore.HealthChecks.Npgsql | db |
| Redis | AspNetCore.HealthChecks.Redis | cache |
| RabbitMQ | AspNetCore.HealthChecks.Rabbitmq | broker |
| A remote URL | AspNetCore.HealthChecks.Uris | external |
Writing your own custom check
Sometimes you need to check something special — maybe a license file, a third-party API key, or a folder of incoming files. You can write your own check by making a class that implements IHealthCheck.
This check looks at how much free disk space is left and decides the status.
using Microsoft.Extensions.Diagnostics.HealthChecks;
public sealed class DiskSpaceHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var drive = new DriveInfo("C");
var freeGb = drive.AvailableFreeSpace / 1_000_000_000d;
if (freeGb < 1)
return Task.FromResult(HealthCheckResult.Unhealthy(
$"Only {freeGb:F1} GB free."));
if (freeGb < 5)
return Task.FromResult(HealthCheckResult.Degraded(
$"Low disk: {freeGb:F1} GB free."));
return Task.FromResult(HealthCheckResult.Healthy(
$"{freeGb:F1} GB free."));
}
}Register it with a name and tag, just like the built-in checks.
builder.Services.AddHealthChecks()
.AddCheck<DiskSpaceHealthCheck>(
name: "disk-space",
tags: new[] { "system", "live" });The CheckHealthAsync method is where all the work happens. Keep it fast and light. A health check should answer in milliseconds, not run a heavy report. Remember the doctor: a quick pulse check, not a full body scan.
Liveness and readiness: the two important questions
This is the part that confuses most beginners, so let us slow down. There are two different questions a robot might ask:
- Liveness — "Are you alive?" Is the app process running and not stuck in a deadlock? If liveness fails, the right fix is to restart the app.
- Readiness — "Are you ready?" Can the app handle a real request right now? During startup it might still be warming caches or waiting for the database. If readiness fails, the right fix is to stop sending traffic for now, but do not restart.
An app can be alive but not ready. This happens all the time at startup.
We use tags to split these. The liveness check should be tiny — just "is the process responding?" — and should not check the database. Why? If the database is down, you do not want Kubernetes to keep restarting your app forever. The app itself is fine; the database is the problem. That is a readiness concern.
Here is how to expose two endpoints using tags. The Predicate decides which checks run for each endpoint.
using Microsoft.Extensions.Diagnostics.HealthChecks;
// Liveness: run only checks tagged "live" (keep these trivial)
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
// Readiness: run only checks tagged "ready" (db, cache, etc.)
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});Now /healthz/live answers the "are you alive?" question with cheap checks, and /healthz/ready answers "are you ready?" by testing the database and cache.
Connecting to Kubernetes
Kubernetes is the most common place health checks get used. It has three kinds of probes, and they map neatly onto what we built.
| Kubernetes probe | Question | If it fails | Maps to |
|---|---|---|---|
| Startup probe | Has the app finished starting? | Wait longer before other probes | /healthz/ready |
| Liveness probe | Is the app alive? | Restart the pod | /healthz/live |
| Readiness probe | Can it take traffic? | Stop sending traffic | /healthz/ready |
This is what the probe section of a Kubernetes deployment looks like.
// This is YAML, shown here for reference inside the article.
// livenessProbe:
// httpGet: { path: /healthz/live, port: 8080 }
// initialDelaySeconds: 10
// periodSeconds: 10
// readinessProbe:
// httpGet: { path: /healthz/ready, port: 8080 }
// initialDelaySeconds: 5
// periodSeconds: 5One very important safety note: Kubernetes probes do not send authentication headers. If you put an authorization requirement on /healthz/live or /healthz/ready, every probe will fail, and Kubernetes will kill your pod in a loop. Keep these simple probe endpoints open. Protect detailed diagnostic endpoints separately.
Kubernetes probe loop
Steps
Startup
Wait until app is up
Liveness
Ping /healthz/live
Readiness
Ping /healthz/ready
Action
Restart or pause traffic
Returning a detailed JSON response
The plain text "Healthy" word is fine for robots, but humans often want detail: which check failed, and how long it took. You can pass a ResponseWriter to write JSON.
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
app.MapHealthChecks("/healthz/details", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
durationMs = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description
})
});
await context.Response.WriteAsync(result);
}
});This endpoint reveals names and timings, so treat it as sensitive. Protect it with authorization, or only expose it on an internal port that the public cannot reach.
A small dashboard with the Health Checks UI
If you like seeing a screen instead of reading JSON, the Xabaril project also offers a UI package. It shows a simple web page with green and red lights for each app you watch, and it can poll many services from one place.
You install AspNetCore.HealthChecks.UI together with a storage package (for example the in-memory one), point it at one or more health endpoints, and map the UI. It is handy for a team dashboard on a big screen. For most small projects, the JSON endpoint plus your existing monitoring tool is enough, so do not feel you must add it.
How the pieces fit together
Let us zoom out. You register checks once, then expose several endpoints, each filtered by tags for a different audience.
This separation is the whole craft of health checks. The same underlying checks serve different needs because tags let each endpoint pick the right subset.
Common mistakes to avoid
A few traps catch almost everyone the first time. Keep this short list handy.
- Checking the database in your liveness probe. If the DB goes down, Kubernetes restarts your app over and over, which never fixes the DB. Keep liveness trivial.
- Slow checks. A health check that takes three seconds will make probes time out. Keep each check fast, and set a timeout on external calls.
- Requiring auth on probe endpoints. Probes send no auth headers, so they will always fail. Keep probe endpoints open; protect detail endpoints.
- Treating Degraded like Unhealthy. Degraded still returns 200 on purpose. Do not pull an app out of rotation just because a cache got a little slow.
- No timeout on external URL checks. A frozen third-party API can freeze your health endpoint. Always cap the wait.
Putting it all together
Here is a compact, complete Program.cs that uses everything we covered: a liveness check, readiness checks for the database and cache, and a custom disk check.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(),
tags: new[] { "live" })
.AddSqlServer(
builder.Configuration.GetConnectionString("Default")!,
name: "sql-server", tags: new[] { "ready" })
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis", tags: new[] { "ready" })
.AddCheck<DiskSpaceHealthCheck>("disk",
tags: new[] { "live" });
var app = builder.Build();
app.MapHealthChecks("/healthz/live", new()
{
Predicate = c => c.Tags.Contains("live")
});
app.MapHealthChecks("/healthz/ready", new()
{
Predicate = c => c.Tags.Contains("ready")
});
app.Run();The "self" check always returns Healthy. It proves the process can respond to requests at all, which is the perfect, cheap liveness signal.
Quick recap
- A health check is a quick check-up for your app. A monitor calls a URL and your app answers Healthy, Degraded, or Unhealthy.
- The core API ships with ASP.NET Core. Register with
AddHealthChecks()and expose withMapHealthChecks("/healthz"). - Three states: Healthy (200), Degraded (200, still working), Unhealthy (503, stop traffic).
- The community Xabaril packages give ready-made checks for SQL Server, PostgreSQL, Redis, RabbitMQ, and more.
- Write your own check with
IHealthCheckand keepCheckHealthAsyncfast. - Split liveness (are you alive — controls restarts) from readiness (are you ready — controls traffic) using tags.
- In Kubernetes, map liveness to
/healthz/liveand readiness to/healthz/ready. Keep probe endpoints open, since probes send no auth headers. - Use a
ResponseWriterfor detailed JSON, but protect that endpoint.
References and further reading
- Health checks in ASP.NET Core — Microsoft Learn
- App health checks in C# — .NET diagnostics — Microsoft Learn
- Xabaril AspNetCore.Diagnostics.HealthChecks (GitHub)
- Kubernetes liveness, readiness and startup probes
- Andrew Lock — Adding health checks with liveness, readiness and startup probes
Related Posts
5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
Containerize Your .NET Applications Without a Dockerfile
Learn how to build container images for your .NET apps using the SDK and dotnet publish, with no Dockerfile needed. Beginner-friendly guide for .NET 10.
How to Set Up Production-Ready Monitoring With ASP.NET Core Health Checks
A friendly, step-by-step guide to production-ready monitoring with ASP.NET Core health checks: liveness, readiness, dependency checks, a UI, and probes.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.
Logging Best Practices in ASP.NET Core: A Beginner's Guide
Learn logging best practices in ASP.NET Core: log levels, structured logging, source-generated LoggerMessage, scopes, correlation IDs, and keeping secrets out.