Health Checks, Readiness and Liveness Probes

Health checks give Kubernetes, load balancers, and monitoring systems a programmatic way to determine whether an API instance is healthy and ready to serve traffic. ASP.NET Core’s built-in health check system supports multiple check types (database connectivity, external services, custom business logic) and exposes them via HTTP endpoints. Kubernetes uses two separate probes: the liveness probe (is the process alive?) restarts a container on failure, and the readiness probe (is it ready to serve traffic?) temporarily removes it from load balancing. Getting these right prevents both stuck processes and premature traffic routing.

Health Check Configuration

// dotnet add package AspNetCore.HealthChecks.SqlServer
// dotnet add package AspNetCore.HealthChecks.Redis

// โ”€โ”€ Program.cs โ€” register health checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
builder.Services
    .AddHealthChecks()
    // Database connectivity check
    .AddSqlServer(
        builder.Configuration.GetConnectionString("Default")!,
        name: "database",
        tags: ["ready"])   // only included in readiness check

    // Redis check
    .AddRedis(
        builder.Configuration.GetConnectionString("Redis")!,
        name: "redis",
        tags: ["ready"])

    // Custom business logic check
    .AddCheck<RequiredSeedDataHealthCheck>("seed-data", tags: ["ready"])

    // Self (always healthy โ€” for liveness)
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]);

// โ”€โ”€ Map health check endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Liveness โ€” is the process alive? (no DB check โ€” it might be the DB that's broken)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live"),
    ResponseWriter = WriteHealthResponse,
});

// Readiness โ€” is it ready to serve requests? (includes DB, Redis checks)
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready") || check.Tags.Contains("live"),
    ResponseWriter = WriteHealthResponse,
});

// Full health endpoint โ€” for internal monitoring only (protect from public)
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = WriteHealthResponse,
}).RequireAuthorization("HealthCheckPolicy");

// โ”€โ”€ Custom JSON response writer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
static Task WriteHealthResponse(HttpContext context, HealthReport report)
{
    context.Response.ContentType = "application/json";
    var result = JsonSerializer.Serialize(new
    {
        status     = report.Status.ToString(),
        duration   = report.TotalDuration.TotalMilliseconds,
        checks     = report.Entries.Select(e => new
        {
            name     = e.Key,
            status   = e.Value.Status.ToString(),
            duration = e.Value.Duration.TotalMilliseconds,
            error    = e.Value.Exception?.Message,
        })
    });
    return context.Response.WriteAsync(result);
}
Note: The critical distinction between liveness and readiness: liveness should only check whether the process itself is functioning (not deadlocked, not out of memory). If the liveness check includes a database query and the database is down, Kubernetes restarts the container โ€” which does not fix the database and causes unnecessary restarts. Readiness checks external dependencies; when readiness fails, Kubernetes stops sending traffic to this instance until it recovers. Same container, different behaviour โ€” design each probe for its intended purpose.
Tip: Add a startup probe for Kubernetes (separate from liveness and readiness) that checks whether database migrations have been applied and seed data exists. This prevents routing traffic to a container that is healthy but has not finished its startup sequence (migrations running, seed data loading). Configure: startupProbe: httpGet: path: /health/startup, failureThreshold: 30, periodSeconds: 10 โ€” gives up to 5 minutes for startup before Kubernetes gives up.
Warning: Never expose detailed health check output (list of checks, individual check statuses, error messages) to public internet endpoints. Health check responses reveal internal service topology: database names, cache servers, third-party service URLs, and error messages. A failed check that includes “Unable to connect to redis:6379” tells attackers about your infrastructure. Expose the full health details only on internal networks or behind authentication; expose only an aggregate status (healthy/unhealthy/degraded) to public probes.

Custom Health Check

// โ”€โ”€ Custom health check โ€” verifies required seed data exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public class RequiredSeedDataHealthCheck(AppDbContext db) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        try
        {
            var roleCount = await db.Roles.CountAsync(ct);
            if (roleCount == 0)
                return HealthCheckResult.Degraded(
                    "Required roles are missing โ€” seed data may not have been applied.");

            var adminExists = await db.Users
                .AnyAsync(u => db.UserRoles
                    .Any(ur => ur.UserId == u.Id &&
                               db.Roles.Any(r => r.Id == ur.RoleId && r.Name == "Admin")), ct);

            return adminExists
                ? HealthCheckResult.Healthy("Seed data is present.")
                : HealthCheckResult.Degraded("Admin user is missing from seed data.");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Seed data check failed.", ex);
        }
    }
}

// Register: builder.Services.AddScoped<RequiredSeedDataHealthCheck>();
// Apply:    .AddCheck<RequiredSeedDataHealthCheck>("seed-data", tags: ["ready"])

Common Mistakes

Mistake 1 โ€” Including database check in liveness probe (unnecessary container restarts)

โŒ Wrong โ€” liveness includes SQL check; DB is slow; container marked unhealthy and restarted unnecessarily.

โœ… Correct โ€” liveness checks process health only; readiness includes DB, Redis, external services.

Mistake 2 โ€” Exposing detailed health info publicly (infrastructure reconnaissance)

โŒ Wrong โ€” /health returns full check list including error messages; public internet can read service topology.

โœ… Correct โ€” require authentication for full health; public /health/live and /health/ready return only aggregate status.

🧠 Test Yourself

The database is down. The liveness probe returns healthy (no DB check). The readiness probe returns unhealthy (DB check fails). What does Kubernetes do?