Custom Health Checks with Spring Boot Actuator
How to implement custom health indicators with Spring Boot Actuator, including database, Redis, external API checks, and Kubernetes readiness probes.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Spring Boot Actuator provides a /actuator/health endpoint that reports the application’s health status. By default, it checks built-in indicators (database, disk space, ping). You can add custom HealthIndicator beans to check anything — external API availability, cache connectivity, queue depth, or feature flags. Kubernetes reads these endpoints to decide whether to route traffic to your pod.
When to Use
- Kubernetes liveness and readiness probes — tell the orchestrator when to restart or route traffic
- Monitoring external dependencies (payment gateway, SMS provider, third-party API)
- Checking resource availability (cache, message queue, file storage)
- Building a status page that aggregates multiple service health checks
- Detecting degraded states before they become full outages
When NOT to Use
- Simple scripts or CLI tools — health checks are for long-running services
- Applications without an orchestrator — if nothing reads the endpoint, the check is wasted CPU
- Replacing metrics — health checks are binary (UP/DOWN), use Micrometer for gradual degradation
Solution
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Enable health endpoint
# application.yml
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
show-components: always
Access at http://localhost:8080/actuator/health.
Basic custom health indicator
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
this.client = client;
}
@Override
public Health health() {
try {
PaymentGatewayStatus status = client.ping();
if (status.isAvailable()) {
return Health.up()
.withDetail("provider", "stripe")
.withDetail("latency_ms", status.getLatencyMs())
.withDetail("version", status.getVersion())
.build();
} else {
return Health.down()
.withDetail("reason", "Gateway returned unavailable")
.withDetail("status_code", status.getStatusCode())
.build();
}
} catch (Exception e) {
return Health.down(e)
.withDetail("error", e.getMessage())
.build();
}
}
}
Database health indicator with custom query
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final JdbcTemplate jdbcTemplate;
public DatabaseHealthIndicator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public Health health() {
try {
Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
int activeConnections = jdbcTemplate.queryForObject(
"SELECT count(*) FROM pg_stat_activity WHERE state = 'active'",
Integer.class
);
return Health.up()
.withDetail("database", "postgresql")
.withDetail("active_connections", activeConnections)
.withDetail("max_connections", 100)
.build();
} catch (Exception e) {
return Health.down(e)
.withDetail("database", "postgresql")
.withDetail("error", "Connection failed")
.build();
}
}
}
Redis health indicator
@Component
public class RedisHealthIndicator implements HealthIndicator {
private final RedisTemplate<String, String> redisTemplate;
public RedisHealthIndicator(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Health health() {
try {
String pong = redisTemplate.getConnectionFactory().getConnection().ping();
long latency = measureLatency();
return Health.up()
.withDetail("redis", "connected")
.withDetail("ping", pong)
.withDetail("latency_ms", latency)
.build();
} catch (Exception e) {
return Health.down(e)
.withDetail("redis", "disconnected")
.build();
}
}
private long measureLatency() {
long start = System.currentTimeMillis();
redisTemplate.opsForValue().get("health-check-key");
return System.currentTimeMillis() - start;
}
}
External API health indicator with timeout
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
private final HttpClient httpClient;
public ExternalApiHealthIndicator() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
}
@Override
public Health health() {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/health"))
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() == 200) {
return Health.up()
.withDetail("url", "https://api.example.com/health")
.withDetail("status_code", response.statusCode())
.build();
} else {
return Health.down()
.withDetail("url", "https://api.example.com/health")
.withDetail("status_code", response.statusCode())
.withDetail("reason", "Non-200 response")
.build();
}
} catch (Exception e) {
return Health.down(e)
.withDetail("url", "https://api.example.com/health")
.withDetail("error", "Connection timeout or failure")
.build();
}
}
}
Queue depth health indicator
@Component
public class QueueDepthHealthIndicator implements HealthIndicator {
private final MessageQueueService queueService;
public QueueDepthHealthIndicator(MessageQueueService queueService) {
this.queueService = queueService;
}
@Override
public Health health() {
int depth = queueService.getQueueDepth("order-processing");
int threshold = 1000;
Map<String, Object> details = new HashMap<>();
details.put("queue", "order-processing");
details.put("depth", depth);
details.put("threshold", threshold);
if (depth < threshold * 0.8) {
return Health.up().withDetails(details).build();
} else if (depth < threshold) {
return Health.up().withDetails(details)
.withDetail("warning", "Queue depth approaching threshold").build();
} else {
return Health.down().withDetails(details)
.withDetail("error", "Queue depth exceeded threshold").build();
}
}
}
Custom health groups
@Component
@HealthGroup("infrastructure")
public class DatabaseHealthIndicator implements HealthIndicator {
// ...
}
@Component
@HealthGroup("infrastructure")
public class RedisHealthIndicator implements HealthIndicator {
// ...
}
@Component
@HealthGroup("external")
public class PaymentGatewayHealthIndicator implements HealthIndicator {
// ...
}
management:
endpoint:
health:
group:
infrastructure:
include: databaseHealthIndicator,redisHealthIndicator
external:
include: paymentGatewayHealthIndicator,externalApiHealthIndicator
Access at /actuator/health/infrastructure and /actuator/health/external.
Kubernetes liveness and readiness probes
management:
endpoint:
health:
probes:
enabled: true
liveness-state:
enabled: true
readiness-state:
enabled: true
# kubernetes deployment
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
Using ApplicationAvailability
import org.springframework.boot.availability.ApplicationAvailability;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.LivenessState;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.event.EventListener;
@Service
public class AvailabilityService {
private final ApplicationAvailability availability;
public AvailabilityService(ApplicationAvailability availability) {
this.availability = availability;
}
@EventListener
public void onLivenessChange(AvailabilityChangeEvent<LivenessState> event) {
System.out.println("Liveness state changed to: " + event.getState());
}
@EventListener
public void onReadinessChange(AvailabilityChangeEvent<ReadinessState> event) {
System.out.println("Readiness state changed to: " + event.getState());
}
public void markUnready() {
AvailabilityChangeEvent.publish(
applicationContext, ReadinessState.REFUSING_TRAFFIC
);
}
}
Variants
Using @HealthIndicator with reactive Spring WebFlux
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.ReactiveHealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class ReactiveApiHealthIndicator implements ReactiveHealthIndicator {
@Override
public Mono<Health> health() {
return checkExternalApi()
.map(status -> Health.up().withDetail("status", status).build())
.onErrorResume(e -> Mono.just(
Health.down(e).withDetail("error", e.getMessage()).build()
));
}
private Mono<String> checkExternalApi() {
return WebClient.create("https://api.example.com")
.get().uri("/health")
.retrieve().bodyToMono(String.class);
}
}
Disabling specific built-in indicators
management:
health:
db:
enabled: false
redis:
enabled: false
disk:
enabled: false
Best Practices
-
For a deeper guide, see Expose Metrics with Micrometer and Prometheus.
-
Set timeouts on external checks — a hanging health check blocks the endpoint and causes Kubernetes to kill the pod
-
Use health groups to separate infrastructure checks from external API checks
-
Return
DOWNonly when the service can’t function — degraded performance should be a warning, not DOWN -
Include useful details (latency, connection count, queue depth) — operators need context to diagnose
-
Enable Kubernetes probes separately from the general health endpoint — liveness should be cheap
-
Cache health check results for a few seconds — calling external APIs on every probe request is wasteful
-
Use
show-details: when_authorizedin production — full details leak infrastructure info to unauthenticated users
Common Mistakes
- No timeout on external checks: a slow external API causes the health endpoint to hang, Kubernetes times out and kills the pod.
- Returning DOWN for non-critical dependencies: if an analytics service is down but the core app works, return UP with a warning detail.
- Checking too many things in one indicator: each indicator should check one dependency. Use health groups to organize.
- Not caching results: Kubernetes calls the probe every 5-10 seconds. External API checks should be cached for 5-10 seconds.
- Exposing full details publicly:
show-details: alwaysleaks database URLs, connection counts, and internal architecture. Usewhen_authorized.
FAQ
What is the difference between liveness and readiness probes?
Liveness tells Kubernetes whether to restart the pod. Readiness tells Kubernetes whether to route traffic to the pod. A pod can be live (running) but not ready (can’t handle requests yet, e.g., warming up cache).
How do I disable all health checks except ping?
management:
health:
defaults:
enabled: false
ping:
enabled: true
Can I return a custom HTTP status code?
Yes. Configure status mapping:
management:
endpoint:
health:
status:
http-mapping:
down: 503
out-of-service: 503
warning: 200
How do I add a custom status level?
@HealthIndicator
public class CustomStatusIndicator implements HealthIndicator {
@Override
public Health health() {
if (isDegraded()) {
return Health.status(new Status("DEGRADED", "Performance is degraded")).build();
}
return Health.up().build();
}
}
Should I check the database in liveness or readiness?
Readiness. If the database is temporarily unavailable, the pod shouldn’t restart — it should just stop receiving traffic. Liveness should only fail if the application itself is stuck (deadlock, infinite loop).
Related Resources
Expose Metrics with Micrometer and Prometheus
How to expose custom application metrics using Micrometer and Prometheus in Spring Boot, including counters, gauges, timers, and histograms.
RecipeExpose Business Metrics with Prometheus
How to expose custom business metrics in Python using prometheus_client, including counters, gauges, histograms, summaries, and Flask integration.
RecipeStructured JSON Logging with structlog
How to emit structured JSON logs in Python using structlog, including context binding, log levels, processors, and integration with standard logging.