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.
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:
- Container orchestrators (Kubernetes, Docker Swarm) — restart a crashed process (liveness) or stop routing to a struggling one (readiness)
- Load balancers (NGINX, ALB, Cloudflare) — pull unhealthy instances out of rotation
- Monitoring (Prometheus, Grafana, HealthChecks-UI) — alerting and dashboards
┌──────────────────────────────────────┐
│ 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.EntityFrameworkCoreservices.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
- Liveness never touches a dependency. Covered above — worth repeating because it's the mistake that causes the worst outages.
- Timeout every probe (2–5s). A hanging DB shouldn't hang your health check, which would then hang the thing polling it.
- Rate-limit or cache readiness checks if multiple load balancer nodes are all polling
/health/ready— you don't want your own health check becoming load on the DB. - Don't leak internals. No connection strings, internal IPs, or raw exception messages in a public-facing readiness response — that's free recon for anyone probing your API. If you want the detailed message for debugging, keep the verbose
HealthCheckResultpayload on the authenticated controller endpoint only, and keep the middleware/health/readyresponse minimal (status + check name, no exception text).
Note on ASP.NET Core health checks — liveness/readiness setup and production gotchas.