← .NET / C#
Programming / .NET / C#Practicing

Health Checks in ASP.NET Core

Liveness vs readiness, manual vs pre-built health checks, and how to expose them for both orchestrators and humans.

DatabasesBackendpostgresrediskubernetes

Why they exist

A health check endpoint answers one question for something external to your app: "should I keep sending traffic here / keep this process running?" The consumers matter because they determine what you check:

                  ┌──────────────────────────────────────┐
                  │           Load Balancer / K8s         │
                  └───────────┬────────────────┬──────────┘
                    /health/live            /health/ready
                              ▼                ▼
                 ┌──────────────────┐   ┌──────────────────────────┐
                 │  Liveness Probe  │   │     Readiness Probe      │
                 │ process alive?   │   │ can reach DB/Redis/deps? │
                 │ 503 → RESTART    │   │ 503 → STOP ROUTING       │
                 └──────────────────┘   └─────────────┬─────────────┘
                                                       │
                                        ┌──────────────┴──────────────┐
                                        ▼                             ▼
                              ┌──────────────────┐         ┌──────────────────┐
                              │  Postgres Check   │         │   Redis Check    │
                              └──────────────────┘         └──────────────────┘

The one rule that matters most: never put a dependency check in liveness. If Postgres blips, liveness failing means Kubernetes restarts every instance simultaneously — you turn a database hiccup into a full outage. Liveness = "is the process able to handle HTTP at all." Readiness = "can I currently do my job."

Method 1 — Manual checks (IHealthCheck)

Full control, no external package. Good when you want to hook into your own DapperContext, check queue depth, disk space, license state, etc. — anything beyond a generic "can I connect."

// Infrastructure/Health/PostgresHealthCheck.cs
public sealed class PostgresHealthCheck(DapperContext dapperContext) : IHealthCheck
{
    private readonly DapperContext _dapperContext = dapperContext;
 
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            using IDbConnection connection = _dapperContext.CreateConnection();
            if (connection is NpgsqlConnection npgsql && npgsql.State != ConnectionState.Open)
            {
                await npgsql.OpenAsync(cancellationToken);
            }
            return HealthCheckResult.Healthy("PostgreSQL is reachable.");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("PostgreSQL health check failed.", ex);
        }
    }
}

Gap worth fixing: this only proves a TCP-level connection opened — it doesn't prove Postgres can actually execute a query (auth OK, DB not in recovery mode, etc.). Run SELECT 1; through the connection instead of just opening it, if you want a real "is it functional" signal rather than "is the socket up."

// API/Health/RedisHealthCheck.cs
public sealed class RedisHealthCheck(IConnectionMultiplexer connectionMultiplexer) : IHealthCheck
{
    private readonly IConnectionMultiplexer _connectionMultiplexer = connectionMultiplexer;
 
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var latency = await _connectionMultiplexer.GetDatabase().PingAsync();
            return HealthCheckResult.Healthy($"Redis is reachable ({latency.TotalMilliseconds:0} ms).");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Redis health check failed.", ex);
        }
    }
}

Registration:

public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration config)
{
    services.AddHealthChecks()
        .AddCheck<PostgresHealthCheck>("postgres", tags: ["ready", "db"])
        .AddCheck<RedisHealthCheck>("redis", tags: ["ready", "cache"]);
    return services;
}

Method 2 — Pre-built packages (AspNetCore.HealthChecks.*)

dotnet add package AspNetCore.HealthChecks.NpgSql
dotnet add package AspNetCore.HealthChecks.Redis
dotnet add package AspNetCore.HealthChecks.Uris
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(name: "app-dbcontext", tags: ["ready", "db"])
    .AddNpgSql(postgresConn, name: "postgres-raw", timeout: TimeSpan.FromSeconds(3), tags: ["ready", "db"])
    .AddRedis(redisConn, name: "redis", timeout: TimeSpan.FromSeconds(2), tags: ["ready", "cache"])
    .AddUrlGroup(new Uri("https://api.open-meteo.com/"), name: "meteo-api", tags: ["ready", "external"]);

AddNpgSql actually runs SELECT 1; under the hood — this is the pre-built equivalent of the fix noted above for the manual check.

| | Manual (IHealthCheck) | Pre-built package | | ----------- | ---------------------------- | ------------------------ | | Effort | Write the probe yourself | One line, NuGet | | Control | Full — custom business logic | Generic infra probe only | | Maintenance | You own it | Community/MS maintained |

Practical take: pre-built for standard infra (Postgres, Redis) since SELECT 1; is a solved problem — no reason to hand-roll it. Manual only when checking something the packages don't cover (custom queue depth, license expiry, internal cache warmth).

Exposing the endpoints — two ways

Middleware routes (MapHealthChecks) — the DevOps-standard path

// Liveness: no dependency checks at all. Pipeline responding = healthy.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false
});
 
// Readiness: only checks tagged "ready" run.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
    ResponseWriter = async (context, report) =>
    {
        context.Response.ContentType = "application/json";
        var result = JsonSerializer.Serialize(new
        {
            status = report.Status.ToString(),
            total_duration_ms = report.TotalDuration.TotalMilliseconds,
            checks = report.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                description = e.Value.Description,
                duration_ms = e.Value.Duration.TotalMilliseconds,
                error = e.Value.Exception?.Message
            })
        });
        await context.Response.WriteAsync(result);
    }
});

Bypasses the MVC pipeline entirely — cheapest possible response, which is exactly what you want for something a load balancer hits every few seconds.

Controller (HealthController + HealthCheckService) — for humans

[ApiController]
[Route("api/[controller]")]
public sealed class HealthController(HealthCheckService healthCheckService) : ControllerBase
{
    private readonly HealthCheckService _healthCheckService = healthCheckService;
 
    [HttpGet]
    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status503ServiceUnavailable)]
    public async Task<IActionResult> Get(CancellationToken cancellationToken)
    {
        HealthReport report = await _healthCheckService.CheckHealthAsync(cancellationToken);
 
        var checks = report.Entries.Select(entry => new
        {
            name = entry.Key,
            status = entry.Value.Status.ToString(),
            description = entry.Value.Description,
            duration = $"{entry.Value.Duration.TotalMilliseconds:0} ms"
        }).ToList();
 
        var data = new
        {
            status = report.Status.ToString(),
            duration = $"{report.TotalDuration.TotalMilliseconds:0} ms",
            checks
        };
 
        var message = report.Status == HealthStatus.Healthy
            ? "All systems are operational."
            : "One or more systems require attention.";
 
        var response = ApiResponse<object>.Ok(data, message);
 
        return report.Status == HealthStatus.Healthy
            ? Ok(response)
            : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
    }
}

Use this when the audience is a person or a UI (admin dashboard, Swagger) that expects your standard ApiResponse<T> envelope — not something a K8s probe should ever call, since it adds MVC routing/filter overhead you don't need for something pinged every second.

| | Middleware (MapHealthChecks) | Controller | | ------- | -------------------------------- | -------------------------------- | | Target | K8s / load balancers | Admin UI, Swagger | | Perf | Fastest — short-circuits MVC | Slight MVC overhead | | Format | Raw JSON, you control the writer | Wrapped in your ApiResponse<T> | | Swagger | Needs a manual doc filter | Automatic |

Production checklist


Note on ASP.NET Core health checks — liveness/readiness setup and production gotchas.