A /health endpoint that always returns 200 is worse than no endpoint: monitors stay green while your SQL pool is exhausted and every real request fails. On Windows hosting, health checks also have to survive IIS app-pool idle timeouts, in-process (ANCM) recycling, and probes that hit the site every few seconds.

Treat health checks as production surface area. Split liveness from readiness, keep dependency probes cheap and cached, and map paths that IIS and your uptime monitor can reach without fighting request filtering or authentication middleware.

#Liveness vs readiness on an IIS app pool

Liveness answers: is this worker process able to run ASP.NET Core at all? Readiness answers: should traffic hit this instance right now? For shared or VPS IIS hosts, that split matters. A recycle or first request after idle can make readiness fail briefly while liveness should still pass once the CLR is up.

  • Liveness: process + pipeline only (no SQL, no remote HTTP). Fast, no I/O beyond memory.
  • Readiness: dependencies the app needs to serve users (SQL Server, critical HTTP APIs, disk for Data Protection keys if you check file presence).
  • Expose separate paths so a load balancer can drain on readiness failure without killing the process on every blip.

#Minimal wiring in ASP.NET Core 10

Register checks in Program.cs, map two endpoints, and keep the live path free of dependency registrations. Use a filtered SQL check on ready only, with a timeout short enough that a hung connection does not tie up the probe thread pool.

csharp
builder.Services.AddHealthChecks()
    .AddSqlServer(
        connectionString: builder.Configuration.GetConnectionString("AppDb")!,
        name: "sql",
        timeout: TimeSpan.FromSeconds(2),
        tags: new[] { "ready" });

var app = builder.Build();

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // process up = healthy
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse // optional detail
});

app.MapGet("/", () => "ok");
app.Run();

If you skip the UI writer package, return the default plain-text status or a small JSON writer you control. Do not dump exception messages or connection strings into public health JSON on an internet-facing site.

#IIS and probe behavior that bites teams

In-process hosting means the health endpoints run inside w3wp.exe with your app. That is good for accuracy and bad if a check blocks: a slow SqlConnection open under load can stall probes and compete with real requests. Cap timeouts, prefer SELECT 1 style checks, and cache readiness results for a few seconds when many monitors hit the same site.

csharp
// Cheap readiness cache so 5s monitor intervals do not open a SQL conn every time
builder.Services.AddHealthChecks()
    .AddCheck<CachedSqlReadyCheck>("sql-cached", tags: new[] { "ready" });

// CachedSqlReadyCheck: run real ping at most once per 5s; return last status otherwise.

Allow anonymous access to the health paths. If the rest of the app uses cookie auth or a global AuthorizeFilter, health maps must be excluded or probes get 401/302 and look “down.” Place MapHealthChecks before heavy middleware that buffers the body, and after exception handling so a check failure still returns a clean status code.

On IIS, confirm the path is not blocked by Request Filtering or a URL Rewrite rule meant for the public site. If you lock down verbs or file extensions aggressively, keep /health/* as an explicit allow. For multi-site servers, bind the monitor to the correct host header and HTTPS binding; SNI mismatches show up as probe failures that never reach Kestrel.

#What to check—and what to skip—on shared Windows hosts

  • Do check: SQL connectivity with a 1–2s timeout; optional existence of a migrations history row if deploy order matters.
  • Do check: writable path for data-protection key ring or upload scratch space if the app cannot run without it.
  • Skip or gate: full EF model validation, outbound calls to third-party SaaS, and “list all rows” queries.
  • Skip on liveness: anything that fails when a dependency is intentionally drained for maintenance.

App pool idle timeout interacts with external monitors. If the pool sleeps after 20 minutes and your uptime check runs hourly, the first probe pays cold-start cost and may trip readiness. Either keep a short idle timeout aligned with probe frequency, use Application Initialization to warm critical URLs, or accept that readiness can flap once after idle and alert only on sustained failure.

xml
<!-- Optional: warm a cheap path after pool start (Application Initialization module) -->
<system.webServer>
  <applicationInitialization doAppInitAfterRestart="true">
    <add initializationPage="/health/live" />
  </applicationInitialization>
</system.webServer>

#Operational checklist

  • Monitor /health/live for process death; alert hard.
  • Monitor /health/ready for dependency loss; page on multi-interval failure, not a single blip.
  • Log check failures to your app log with check name only—no secrets.
  • After Web Deploy, hit ready once in the release pipeline before switching traffic or declaring success.

Practical takeaway: ship two endpoints, keep liveness dependency-free, bound every readiness I/O with a short timeout and light caching, and verify IIS auth, rewrite, and filtering let probes through on the real hostname. That combination catches dead SQL and dead workers without false pages every time an app pool recycles on a Windows host.