Skip to main content

ASP.NET Core Health Checks: Complete Guide with Examples

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 timeout parameter on registration and short HttpClient timeouts. 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/ready to your internal network (RequireHost, IP allow-lists, or RequireAuthorization()) 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 1 is 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 /health endpoint 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 initialDelaySeconds or 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 with MapHealthChecks() — it takes two lines to get started.
  • Always expose separate /health/live and /health/ready endpoints using tags and a Predicate.
  • Use community packages for SQL Server, Redis, RabbitMQ, and more; implement IHealthCheck for business-specific conditions.
  • Prefer Degraded over Unhealthy for 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...