Skip to content
fullstackhero

Concept

Health checks

Liveness + readiness endpoints — per-module database checks, Valkey, Hangfire, tenant migrations — for Kubernetes / Docker Compose / load balancer probes.

views 0 Last updated

Two health endpoints ship out of the box: GET /health/live for liveness, GET /health/ready for readiness. Both are anonymous and exempt from rate limiting. The kit registers checks for every dependency the host needs to function — one database check per module DbContext, Valkey (a Redis-compatible, BSD-licensed Redis fork) when caching is enabled, Hangfire when jobs are enabled, and a per-tenant migration check that reports whether every tenant DB is on the latest schema.

What’s checked

CheckSourceWhen registered
selfAddHeroPlatformAlways
db:{module} (e.g. db:identity, db:catalog, db:billing…)AddDbContextCheck<T> in each module’s registrationOne per module DbContext
redisKit’s RedisHealthCheckWhen EnableCaching = true and CachingOptions:Redis is set
hangfireKit’s HangfireHealthCheckWhen EnableJobs = true
db:tenants-migrationsKit’s TenantMigrationsHealthCheck (Multitenancy module)Always (with the Multitenancy module)

There is no storage/MinIO health check — blob storage failures surface through the Files module’s own error handling, not readiness.

How to wire probes

Kubernetes

livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5

Docker Compose

services:
api:
image: fsh-api:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s
timeout: 5s
retries: 3

Cloud load balancers

Point them at /health/ready — they’ll only route traffic to instances that report ready.

Response shape

The kit serializes its own compact shape (not the default ASP.NET Core health JSON):

GET /health/live HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{ "status": "Healthy", "results": [] }
GET /health/ready HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "Healthy",
"results": [
{ "name": "self", "status": "Healthy", "description": null, "durationMs": 0.01 },
{ "name": "db:identity", "status": "Healthy", "description": null, "durationMs": 12.3 },
{ "name": "db:catalog", "status": "Healthy", "description": null, "durationMs": 8.7 },
{ "name": "redis", "status": "Healthy", "description": null, "durationMs": 1.9 },
{ "name": "db:tenants-migrations", "status": "Healthy", "description": "All tenants are at the head migration.", "durationMs": 41.0, "details": { /* per-tenant data */ } }
]
}

When any check fails, the overall response is HTTP 503 — with the same body shape, so operators can see exactly which check failed while probe consumers key off the status code alone.

Adding your own check

Implement IHealthCheck and register it during module startup:

public sealed class CustomerApiHealthCheck(HttpClient http) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct)
{
try
{
using var resp = await http.GetAsync("/health", ct).ConfigureAwait(false);
return resp.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded($"Upstream returned {(int)resp.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Upstream unreachable", ex);
}
}
}
// register in the module's ConfigureServices
services.AddHealthChecks().AddCheck<CustomerApiHealthCheck>("customer-api");

Every check registered with AddHealthChecks() automatically participates in /health/ready — liveness runs no checks, so there’s nothing to tag or filter. Keep slow checks (cross-region pings, third-party APIs) out of the registry or make them cheap; readiness latency is probe latency.

Tenant migrations check

TenantMigrationsHealthCheck is unusual — it iterates every tenant, sets that tenant’s Finbuckle context, and asks EF Core for pending migrations. Per-tenant detail lands in the check’s data:

{
"name": "db:tenants-migrations",
"status": "Unhealthy",
"description": "Tenant schema is not at head — pending migrations for tenant(s): acme. Run FSH.Starter.DbMigrator to apply pending migrations.",
"details": {
"acme": { "name": "Acme", "isActive": true, "hasPendingMigrations": true, "pendingMigrations": ["20260601_AddX"] }
}
}

A tenant with pending migrations (or one whose probe throws) makes the check Unhealthy, which makes /health/ready return 503 — Kubernetes keeps the pod out of rotation until the standalone FSH.Starter.DbMigrator catches the schema up. That’s deliberate: the DB is never migrated at API startup, so a pod running newer code against an older schema must not take traffic.

What health checks don’t tell you

  • Per-endpoint health/health/ready reports infrastructure state, not “is this specific endpoint working.” Use synthetic monitoring (probing real endpoints with known payloads) for that.
  • Latency — health is binary; latency is a spectrum. Use the OpenTelemetry HTTP server metrics for latency SLOs.
  • Partial tenant outages — the migrations check covers schema drift, but a single tenant’s connectivity problem in a tenant-per-database setup needs its own aggregation if your SLA covers per-tenant availability.