Skip to content

How to Write Custom Health Checks in Spring Boot Actuator

I deployed my Spring Boot application to Kubernetes and set up liveness and readiness probes. Everything looked fine until I realized Kubernetes was restarting my pods even when a critical external API was down. The health endpoint returned “UP” because my database connection was healthy, but my application was functionally broken.

The problem? Spring Boot’s built-in health indicators only check infrastructure like databases, Redis, and MongoDB. They don’t know about my application’s specific health requirements.

The Problem

I had this setup in my application.yml:

application.yml
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: always

When I hit /actuator/health, I got:

Health Response
{
"status": "UP",
"components": {
"db": {"status": "UP"},
"diskSpace": {"status": "UP"},
"ping": {"status": "UP"}
}
}

But my application depended on an external payment API that was down. The health endpoint said everything was fine, but payment requests were failing. Kubernetes had no way to know the application was degraded.

First Attempt: Check in Controller

I initially tried to check the API health in a controller:

PaymentController.java
@RestController
public class PaymentController {
@GetMapping("/pay")
public ResponseEntity<String> pay() {
if (!paymentService.isApiAvailable()) {
return ResponseEntity.status(503).body("Payment API unavailable");
}
// process payment
}
}

This was wrong. The endpoint returned errors, but Kubernetes liveness/readiness probes check /actuator/health, not my business endpoints. The pods stayed “healthy” and continued receiving traffic they couldn’t handle.

The Solution: Custom Health Indicator

Spring Boot Actuator uses the HealthIndicator interface for health checks. I needed to implement my own to check the payment API.

Basic Implementation

PaymentApiHealthIndicator.java
@Component
public class PaymentApiHealthIndicator implements HealthIndicator {
private final PaymentService paymentService;
public PaymentApiHealthIndicator(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Override
public Health health() {
if (paymentService.isApiAvailable()) {
return Health.up().build();
}
return Health.down().build();
}
}

After adding this class, the health endpoint now included my custom check:

Updated Health Response
{
"status": "DOWN",
"components": {
"db": {"status": "UP"},
"diskSpace": {"status": "UP"},
"paymentApi": {"status": "DOWN"},
"ping": {"status": "UP"}
}
}

The naming convention is automatic: PaymentApiHealthIndicator becomes “paymentApi” in the health response.

Adding Details for Debugging

The basic version worked, but I wanted more information when checking health:

PaymentApiHealthIndicator.java
@Component
public class PaymentApiHealthIndicator implements HealthIndicator {
private final PaymentService paymentService;
public PaymentApiHealthIndicator(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Override
public Health health() {
try {
ApiHealthStatus status = paymentService.checkHealth();
if (status.isHealthy()) {
return Health.up()
.withDetail("apiEndpoint", status.getEndpoint())
.withDetail("responseTime", status.getResponseTime() + "ms")
.withDetail("lastCheck", status.getTimestamp())
.build();
} else {
return Health.down()
.withDetail("error", status.getErrorMessage())
.withDetail("lastSuccessfulCheck", status.getLastSuccess())
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("error", "Health check failed: " + e.getMessage())
.withException(e)
.build();
}
}
}

Now the health response included useful debugging information:

Health Response with Details
{
"status": "DOWN",
"components": {
"paymentApi": {
"status": "DOWN",
"details": {
"error": "Connection timeout after 5000ms",
"lastSuccessfulCheck": "2026-03-28T09:30:00Z"
}
}
}
}

Why This Works

Spring Boot automatically discovers all beans implementing HealthIndicator and includes them in the aggregated health response. The overall status is determined by a status aggregator:

  • If any indicator returns DOWN, the overall status is DOWN
  • The default order is DOWN < OUT_OF_SERVICE < UP < UNKNOWN

This means Kubernetes can now make informed decisions about pod health based on your application’s actual requirements.

Handling Exceptions Properly

I initially made a mistake. When my health check threw an exception, I expected it to return DOWN. But I saw this in the logs:

Console Output
Health check failed for paymentApi: java.net.ConnectException: Connection refused

The health endpoint returned DOWN, but the error handling wasn’t clean. The best practice is to catch exceptions inside the health() method:

Incorrect Approach
@Override
public Health health() {
// This can throw and cause unclear error messages
if (paymentService.isApiAvailable()) {
return Health.up().build();
}
return Health.down().build();
}
Correct Approach
@Override
public Health health() {
try {
if (paymentService.isApiAvailable()) {
return Health.up()
.withDetail("status", "available")
.build();
}
return Health.down()
.withDetail("reason", "API unavailable")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.withException(e)
.build();
}
}

Configuring for Kubernetes Liveness and Readiness

Spring Boot 2.3+ supports separate liveness and readiness probes. I configured my custom indicator to be part of the readiness group:

application.yml
management:
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
group:
liveness:
include: ping
readiness:
include: db,paymentApi

Now Kubernetes can use:

  • /actuator/health/liveness - Is the app running? (restart if not)
  • /actuator/health/readiness - Can the app serve traffic? (stop sending traffic if not)

When my payment API is down, the readiness probe fails and Kubernetes stops routing traffic to that pod, but doesn’t restart it unnecessarily.

Reactive Health Indicators for WebFlux

When I moved to Spring WebFlux, I needed a different approach. The blocking HealthIndicator doesn’t work well with reactive stacks.

ReactivePaymentApiHealthIndicator.java
@Component
public class ReactivePaymentApiHealthIndicator implements ReactiveHealthIndicator {
private final WebClient webClient;
public ReactivePaymentApiHealthIndicator(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder
.baseUrl("https://api.payment.com")
.build();
}
@Override
public Mono<Health> health() {
return webClient.get()
.uri("/health")
.retrieve()
.bodyToMono(String.class)
.map(response -> Health.up()
.withDetail("response", response)
.build())
.onErrorResume(e -> Mono.just(Health.down()
.withDetail("error", e.getMessage())
.build()))
.timeout(Duration.ofSeconds(5));
}
}

The key difference: return Mono<Health> instead of Health, and use reactive operators like map and onErrorResume.

Common Mistakes to Avoid

1. Slow Health Checks

I initially called a slow API in my health check:

Problematic Slow Check
@Override
public Health health() {
// This blocks for 10+ seconds when API is slow
boolean available = paymentService.isApiAvailable();
// ...
}

This caused the entire health endpoint to hang. Kubernetes would timeout and restart pods unnecessarily. I added a timeout:

Health Check with Timeout
@Override
public Health health() {
try {
boolean available = paymentService
.isApiAvailableWithTimeout(2, TimeUnit.SECONDS);
// ...
} catch (TimeoutException e) {
return Health.down()
.withDetail("error", "Health check timeout")
.build();
}
}

2. Exposing Sensitive Information

I initially included API keys in health details:

Security Risk
return Health.up()
.withDetail("apiKey", paymentService.getApiKey()) // DON'T DO THIS
.build();

This is a security risk. Health endpoints are often accessible to monitoring systems. Only include non-sensitive diagnostic information.

3. Using Unknown Status Inappropriately

I tried using Health.unknown() for transient failures:

Incorrect Status Usage
if (isTransientFailure()) {
return Health.unknown().build(); // This doesn't affect overall status
}

But unknown() status doesn’t affect the aggregated health. Use down() with details explaining the nature of the failure:

Better Approach
if (isTransientFailure()) {
return Health.down()
.withDetail("type", "transient")
.withDetail("retryAfter", "30s")
.build();
}

Testing Custom Health Indicators

I wrote a test to verify my health indicator works correctly:

PaymentApiHealthIndicatorTest.java
@SpringBootTest
class PaymentApiHealthIndicatorTest {
@Autowired
private HealthIndicator paymentApiHealthIndicator;
@Test
void shouldReturnUpWhenApiIsAvailable() {
Health health = paymentApiHealthIndicator.health();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health.getDetails()).containsKey("apiEndpoint");
}
@Test
void shouldReturnDownWhenApiIsUnavailable(
@MockBean PaymentService paymentService) {
when(paymentService.isApiAvailable()).thenReturn(false);
Health health = paymentApiHealthIndicator.health();
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
}
}

Summary

To implement custom health checks in Spring Boot Actuator:

  1. Create a class implementing HealthIndicator
  2. Annotate with @Component for automatic detection
  3. Return Health.up() or Health.down() with optional details
  4. Handle exceptions inside the health() method
  5. Use ReactiveHealthIndicator for WebFlux applications
  6. Configure health groups for Kubernetes liveness/readiness probes

The naming convention is automatic: XyzHealthIndicator becomes “xyz” in the health response. This lets you monitor what actually matters for your application, not just databases and caches.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments