Learn how to implement ASP.NET Core health checks to monitor databases, APIs & services. Step-by-step tutorial with code examples. Start monitoring now!
ASP.NET Core Health Checks: Monitor Your Production App Like a Pro
Your app is deployed. Users are active. Then at 2 AM, the database silently dies and nobody knows until customers start tweeting. Sound familiar? ASP.NET Core health checks solve this exact problem by giving you real-time visibility into the state of your application and its dependencies — databases, external APIs, message queues, disk storage, and more.
In this guide, you'll learn how to implement health checks in ASP.NET Core from scratch, build custom health check logic, integrate with Kubernetes probes, and follow production-tested best practices that keep your systems observable and reliable.
What Are Health Checks in ASP.NET Core?
Health checks are HTTP endpoints that report whether your application and its dependencies are functioning correctly. ASP.NET Core ships with built-in health check middleware (the Microsoft.Extensions.Diagnostics.HealthChecks package) that makes it straightforward to expose /health endpoints returning one of three statuses:
- Healthy — everything is working as expected.
- Degraded — the app is running but something is slower or partially broken.
- Unhealthy — a critical dependency has failed.
Load balancers, container orchestrators like Kubernetes, and monitoring tools such as Prometheus and Grafana poll these endpoints to make automated decisions — restarting containers, pulling instances from rotation, or firing alerts.
Setting Up a Basic Health Check Endpoint
ASP.NET Core 6+ includes health check support out of the box. No extra NuGet packages are needed for the basics.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register health check services
builder.Services.AddHealthChecks();
var app = builder.Build();
// Map the health check endpoint
app.MapHealthChecks("/health");
app.Run();
Run your application and hit GET /health. You'll receive a 200 OK response with the body Healthy. That's the simplest possible health check — it confirms the app process is alive and can handle HTTP requests.
But a process being alive doesn't mean your app is working. Let's add real dependency checks.
Adding Database Health Checks
Most production issues trace back to database connectivity. The AspNetCore.HealthChecks.SqlServer NuGet package (part of the popular Xabaril health checks library) provides ready-made checks for SQL Server, PostgreSQL, MySQL, and more.
// Install: dotnet add package AspNetCore.HealthChecks.SqlServer
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
healthQuery: "SELECT 1;",
name: "sql-server",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "sql", "critical" });
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
This check opens a connection to SQL Server and runs SELECT 1. If the connection times out or the query fails, the endpoint returns Unhealthy. The tags parameter becomes important later when you want to run different checks for different scenarios.
Entity Framework Core Health Check
If you're using EF Core, there's a built-in package that verifies your DbContext can connect:
// Install: dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
builder.Services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>(
name: "ef-core-db",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "ef-core" });
This calls DbContext.Database.CanConnectAsync() under the hood — simple, but effective for catching connection string misconfigurations, network partitions, or database server outages.
Writing a Custom Health Check in ASP.NET Core
Pre-built checks cover common scenarios, but your application has unique dependencies — a third-party payment API, a file share, a Redis cache, a machine learning inference endpoint. Writing a custom health check in C# means implementing the IHealthCheck interface:
using Microsoft.Extensions.Diagnostics.HealthChecks;
public class PaymentGatewayHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
public PaymentGatewayHealthCheck(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient("PaymentGateway");
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("/api/status", cancellationToken);
if (response.IsSuccessStatusCode)
{
return HealthCheckResult.Healthy("Payment gateway is reachable.");
}
return HealthCheckResult.Degraded(
$"Payment gateway returned {response.StatusCode}.");
}
catch (HttpRequestException ex)
{
return HealthCheckResult.Unhealthy(
"Payment gateway is unreachable.", ex);
}
}
}
Register it in Program.cs:
builder.Services.AddHttpClient("PaymentGateway", client =>
{
client.BaseAddress = new Uri("https://api.paymentprovider.com");
client.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddHealthChecks()
.AddCheck<PaymentGatewayHealthCheck>(
"payment-gateway",
failureStatus: HealthStatus.Degraded,
tags: new[] { "external", "payment" });
Notice we set failureStatus to Degraded rather than Unhealthy. This is a design decision: if the payment gateway is down, users can still browse products — the app isn't completely broken. Reserve Unhealthy for dependencies whose failure means the application cannot serve its core purpose.
Returning Detailed JSON Health Check Responses
The default health check endpoint returns a plain text status. In production, you want structured data — which checks passed, which failed, and how long each took. Use a custom ResponseWriter:
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.HealthChecks;
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
exception = e.Value.Exception?.Message,
data = e.Value.Data
})
};
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
await context.Response.WriteAsync(json);
}
});
Now GET /health returns something like this:
// Sample JSON response
{
"status": "Degraded",
"totalDuration": 245.12,
"checks": [
{
"name": "sql-server",
"status": "Healthy",
"description": null,
"duration": 12.4,
"exception": null,
"data": {}
},
{
"name": "payment-gateway",
"status": "Degraded",
"description": "Payment gateway returned ServiceUnavailable.",
"duration": 232.7,
"exception": null,
"data": {}
}
]
}
ASP.NET Core Readiness and Liveness Probes for Kubernetes
If you're deploying to Kubernetes (or Docker Swarm, or Azure Container Apps), you need separate endpoints for liveness and readiness probes. This is where health check tags shine:
- Liveness — Is the process alive? If not, Kubernetes kills and restarts the pod.
- Readiness — Can the app handle traffic? If not, Kubernetes stops routing requests to it but keeps the pod running.
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddSqlServer(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "sql-server",
tags: new[] { "ready", "db" })
.AddCheck<PaymentGatewayHealthCheck>(
"payment-gateway",
failureStatus: HealthStatus.Degraded,
tags: new[] { "ready", "external" });
// 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")
});
In your Kubernetes deployment manifest:
// Kubernetes YAML (shown here for context)
// livenessProbe:
// httpGet:
// path: /health/live
// port: 8080
// initialDelaySeconds: 5
// periodSeconds: 10
//
// readinessProbe:
// httpGet:
// path: /health/ready
// port: 8080
// initialDelaySeconds: 10
// periodSeconds: 15
This separation prevents Kubernetes from restarting your pod just because a downstream API is temporarily unavailable. The pod stays alive (liveness passes) but is removed from the service load balancer (readiness fails) until the dependency recovers.
Health Check UI: Visual Dashboard
For teams that want a visual dashboard without building one from scratch, the AspNetCore.HealthChecks.UI package provides a ready-made web interface:
// Install packages:
// dotnet add package AspNetCore.HealthChecks.UI
// dotnet add package AspNetCore.HealthChecks.UI.InMemory.Storage
builder.Services.AddHealthChecksUI(options =>
{
options.SetEvaluationTimeInSeconds(30);
options.MaximumHistoryEntriesPerEndpoint(50);
options.AddHealthCheckEndpoint("API", "/health");
})
.AddInMemoryStorage();
var app = builder.Build();
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecksUI(options =>
{
options.UIPath = "/health-ui";
});
Navigate to /health-ui and you get a real-time dashboard showing every registered check, its status history, and response times. It's an excellent tool for dev and staging environments. For production, you'll likely push health data to Prometheus, Datadog, or Application Insights instead.
Securing Your Health Check Endpoints
Health check endpoints can leak sensitive information — database connection status, internal service names, error messages. Never expose detailed health checks publicly without protection:
// Option 1: Require authorization
app.MapHealthChecks("/health/detail", new HealthCheckOptions
{
ResponseWriter = WriteDetailedResponse
}).RequireAuthorization("HealthCheckPolicy");
// Option 2: Restrict by network — simple and effective
app.MapHealthChecks("/health/detail", new HealthCheckOptions
{
ResponseWriter = WriteDetailedResponse
}).RequireHost("internal.myapp.com");
// Option 3: Minimal info for public endpoint
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("public"),
ResponseWriter = (context, report) =>
{
context.Response.ContentType = "text/plain";
return context.Response.WriteAsync(report.Status.ToString());
}
});
A good pattern: expose a simple /health endpoint publicly (for load balancers) that returns only the status word, and a detailed /health/detail endpoint behind authentication for your operations team.
Configuring Timeouts and Intervals
A health check that takes 30 seconds to time out is worse than no health check — it blocks the endpoint and gives stale results. Always configure aggressive timeouts:
builder.Services.AddHealthChecks()
.AddSqlServer(
connectionString: connectionString,
name: "sql-server",
timeout: TimeSpan.FromSeconds(3), // Fail fast
tags: new[] { "ready" })
.AddCheck<PaymentGatewayHealthCheck>(
"payment-gateway",
timeout: TimeSpan.FromSeconds(5),
tags: new[] { "ready" });
As a rule of thumb: health check timeouts should be significantly shorter than your probe intervals. If Kubernetes polls every 15 seconds, your health check should complete in under 5 seconds.
Publishing Health Check Results
Beyond the HTTP endpoints, you can push health status to external systems using IHealthCheckPublisher. This is useful for centralized monitoring when you can't rely on pull-based polling:
public class ApplicationInsightsPublisher : IHealthCheckPublisher
{
private readonly TelemetryClient _telemetryClient;
public ApplicationInsightsPublisher(TelemetryClient telemetryClient)
{
_telemetryClient = telemetryClient;
}
public Task PublishAsync(
HealthReport report, CancellationToken cancellationToken)
{
foreach (var entry in report.Entries)
{
_telemetryClient.TrackMetric(
$"HealthCheck.{entry.Key}",
entry.Value.Status == HealthStatus.Healthy ? 1 : 0);
if (entry.Value.Status != HealthStatus.Healthy)
{
_telemetryClient.TrackEvent("HealthCheckFailed", new Dictionary<string, string>
{
["CheckName"] = entry.Key,
["Status"] = entry.Value.Status.ToString(),
["Description"] = entry.Value.Description ?? "N/A"
});
}
}
return Task.CompletedTask;
}
}
// Register the publisher
builder.Services.Configure<HealthCheckPublisherOptions>(options =>
{
options.Delay = TimeSpan.FromSeconds(5);
options.Period = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton<IHealthCheckPublisher, ApplicationInsightsPublisher>();
Best Practices for ASP.NET Core Health Checks
After implementing health checks across dozens of production services, these are the patterns that matter most:
- Separate liveness from readiness. A process that's alive but can't reach the database shouldn't be killed — it should be temporarily removed from rotation.
- Set explicit timeouts. Default timeouts are too generous. A health check that hangs for 30 seconds cascades into load balancer timeouts and user-facing errors.
- Don't check everything in one endpoint. Use tags to group checks by purpose. Your load balancer doesn't need to know if your email provider is down.
- Use Degraded status wisely. Not every failure is catastrophic. If a caching layer is down, the app is slower but functional — that's
Degraded, notUnhealthy. - Avoid expensive checks. A health check that runs a complex database query or triggers I/O-heavy operations can itself become a performance problem when polled frequently.
- Secure detailed endpoints. Expose minimal information publicly. Detailed diagnostics belong behind authentication or on internal networks only.
- Monitor the monitors. If your health check polling system goes down, you lose visibility. Ensure your observability stack itself has redundancy.
- Test failure scenarios. Don't just test that checks return
Healthy. Simulate database outages, network timeouts, and disk pressure to verify your checks detect real problems.
Common Pitfalls to Avoid
Even experienced teams make these mistakes with health check implementations:
- Checking too many dependencies in the liveness probe. This causes unnecessary pod restarts when a downstream service blips. Liveness should only verify the process itself.
- No caching of results. If you have 10 instances being polled every 10 seconds, that's 60 health check requests per minute hitting your database. Consider caching results for a short window.
- Ignoring startup health checks. ASP.NET Core 8+ supports
MapHealthCheckswith a"startup"tag pattern. Use it to prevent traffic from reaching pods that haven't finished warming up. - Returning 200 for Degraded status. By default, both
HealthyandDegradedreturn HTTP 200, whileUnhealthyreturns 503. CustomizeResultStatusCodesif your load balancer needs to distinguish these.
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status200OK,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
});
Conclusion
Implementing ASP.NET Core health checks is one of the highest-value, lowest-effort improvements you can make to a production application. In this guide, you've learned how to set up basic and custom health checks, return detailed JSON responses, configure Kubernetes liveness and readiness probes, secure your endpoints, and publish results to external monitoring systems.
The key takeaways: always separate liveness from readiness, set aggressive timeouts, use tags to organize checks by purpose, and secure detailed health information behind authentication. Start with database and critical API checks, then expand coverage as your monitoring maturity grows.
Health checks aren't just a devops checkbox — they're the foundation of observable, self-healing systems. Add them to your next deployment and you'll sleep better at 2 AM.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment