Spring Boot Actuator: Health Checks and Metrics
1. Overview
Any service running on production needs to answer two questions: “Is it still alive?” and “How is it performing?”. Spring Boot Actuator answers both through built-in endpoints, primarily /actuator/health and /actuator/metrics.
In this article, you’ll enable Actuator, write a custom HealthIndicator, and configure liveness/readiness probes for Kubernetes. Then you’ll record business metrics using Micrometer, export them to Prometheus, and write tests for both parts.
2. Setup
You need to add the Actuator starter. The example in this article also adds spring-boot-starter-web so that endpoints are served over HTTP:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Start the application and try calling:
curl http://localhost:8080/actuator
{"_links":{"self":{...},"health":{...},"health-path":{...}}}
By default, Spring Boot 3.x only exposes the health endpoint over HTTP. Other endpoints still exist but are not accessible from outside. This is a deliberate safety choice.
2.1. Exposing More Endpoints
You list the endpoints you want to open in application.properties:
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always
The first line opens three endpoints: health, info, and metrics. The second line allows /actuator/health to return details for each component instead of just an overall status.
Note: Avoid using
management.endpoints.web.exposure.include=*on production. Endpoints likeenv,heapdump, orconfigpropscan expose passwords, internal configuration, and in-memory data.
3. Health Checks
3.1. Default Endpoint
With the configuration above, calling /actuator/health returns something like:
{
"status": "UP",
"components": {
"diskSpace": {
"status": "UP",
"details": { "total": 499963174912, "free": 213456789504, "threshold": 10485760 }
},
"ping": { "status": "UP" }
}
}
Spring Boot automatically registers HealthIndicator beans based on the classpath. If you have a DataSource, you’ll see an additional db. If you have Redis, then redis, and similarly for other components. The overall status is an aggregate: if even one component is DOWN, the entire status is DOWN, and the HTTP status returned is 503.
3.2. Writing a Custom HealthIndicator
Suppose your order service depends on an external payment gateway. First, we have a simple client:
public interface PaymentGatewayClient {
boolean ping();
}
Then write an indicator to check this client:
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 {
if (client.ping()) {
return Health.up().withDetail("provider", "VNPay").build();
}
return Health.down().withDetail("reason", "ping returned false").build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
A few things to note:
- The bean name is
paymentGatewayHealthIndicator. Spring Boot drops theHealthIndicatorsuffix, so the key in JSON ispaymentGateway. withDetail(...)adds supplementary information, displayed only whenshow-detailsallows it.Health.down(e)automatically includes the error message indetails.error.
The result when the payment gateway fails:
{
"status": "DOWN",
"components": {
"diskSpace": { "status": "UP", ... },
"paymentGateway": {
"status": "DOWN",
"details": { "error": "java.net.ConnectException: Connection refused" }
},
"ping": { "status": "UP" }
}
}
Note: The
health()method runs each time the endpoint is called. If it makes network calls without a timeout, a slow dependency will cause/actuator/healthto hang. You should always set a short timeout (e.g., 1–2 seconds) for clients used in health checks.
3.3. Liveness and Readiness Probes
Kubernetes distinguishes between two types of probes:
- Liveness: Is the application hung? If it fails, the pod is restarted.
- Readiness: Is the application ready to accept requests? If it fails, the pod is removed from the load balancer but not restarted.
Spring Boot automatically enables these two probe groups when it detects it’s running on Kubernetes. When running locally, you enable them manually:
management.endpoint.health.probes.enabled=true
management.endpoint.health.group.readiness.include=readinessState,paymentGateway
The first line creates two endpoints: /actuator/health/liveness and /actuator/health/readiness. The second line adds paymentGateway to the readiness group. When the payment gateway goes down, the pod only stops receiving traffic instead of being unnecessarily restarted.
curl http://localhost:8080/actuator/health/liveness
{"status":"UP"}
Corresponding configuration in a Kubernetes manifest:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
Note: Don’t include external dependencies (database, payment API) in the liveness group. When the database goes down, all pods will fail liveness and restart simultaneously. Restarting won’t fix the database and only adds more load during startup.
4. Metrics with Micrometer
Actuator uses Micrometer as the abstraction layer for metrics. Without any additional configuration, you already have JVM metrics, CPU, HTTP requests, connection pools, and much more.
curl http://localhost:8080/actuator/metrics
{"names":["application.ready.time","http.server.requests","jvm.gc.pause","jvm.memory.used","process.cpu.usage", ...]}
To see details of a specific metric, you add its name to the URL. You can filter by tag using the tag parameter:
curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/orders"
4.1. Counter for Order Count
System metrics alone are not enough; you often need business metrics too. The example below counts the number of orders created, broken down by sales channel:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final MeterRegistry registry;
public OrderService(MeterRegistry registry) {
this.registry = registry;
}
public Order createOrder(Customer customer, String channel) {
Order order = new Order(customer.id(), channel);
// ... save order
Counter.builder("orders.created")
.description("Number of orders created")
.tag("channel", channel)
.register(registry)
.increment();
return order;
}
}
public record Customer(long id, String name) {}
public record Order(long customerId, String channel) {}
MeterRegistry is automatically configured and injected by Spring Boot. Calling register(registry) multiple times with the same name and tags is safe, because Micrometer returns the existing counter. After creating three orders via the web channel:
curl http://localhost:8080/actuator/metrics/orders.created
{
"name": "orders.created",
"description": "Number of orders created",
"measurements": [ { "statistic": "COUNT", "value": 3.0 } ],
"availableTags": [ { "tag": "channel", "values": ["web"] } ]
}
4.2. Timer for Payment Processing Time
Timer both counts the number of calls and measures the total time and maximum time:
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final Timer paymentTimer;
public PaymentService(MeterRegistry registry) {
this.paymentTimer = Timer.builder("payment.process")
.description("Payment processing time")
.register(registry);
}
public boolean pay(Order order) {
return paymentTimer.record(() -> {
// ... call payment gateway
return true;
});
}
}
The timer is created once in the constructor and reused, since there are no tags that change per call. record(Supplier) measures the execution time of the lambda and returns its result. The /actuator/metrics/payment.process endpoint displays three statistics: COUNT, TOTAL_TIME, and MAX.
4.3. Common Tags for All Metrics
When multiple services push metrics to a single place, you should attach the application name to every metric:
management.metrics.tags.application=order-service
Note: Don’t use tags with large or unbounded value sets, such as
orderId,customerId, or email. Each unique tag combination creates a separate time series. Millions of orders will create millions of series, bloating both the application’s memory and the monitoring system’s storage. Tags should only contain small, fixed sets of values likechannel,status, orregion.
5. Exporting to Prometheus
The /actuator/metrics endpoint is good for quick checks. For long-term storage and graphing, you need a system like Prometheus. Add the corresponding registry:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Then expose the prometheus endpoint:
management.endpoints.web.exposure.include=health,info,metrics,prometheus
curl http://localhost:8080/actuator/prometheus | grep -E "orders|payment"
# HELP orders_created_total Number of orders created
# TYPE orders_created_total counter
orders_created_total{application="order-service",channel="web"} 3.0
payment_process_seconds_count{application="order-service"} 5.0
payment_process_seconds_sum{application="order-service"} 0.412
payment_process_seconds_max{application="order-service"} 0.153
Micrometer automatically renames metrics according to Prometheus conventions: dots become underscores, counters get the _total suffix, timers are in seconds. The rest is configuring Prometheus to scrape the /actuator/prometheus path.
6. Testing
6.1. Test HealthIndicator
HealthIndicator is a regular class, so you can test it without starting the Spring context:
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;
import static org.assertj.core.api.Assertions.assertThat;
class PaymentGatewayHealthIndicatorTest {
@Test
void whenPingSucceeds_thenStatusIsUp() {
var indicator = new PaymentGatewayHealthIndicator(() -> true);
Health health = indicator.health();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health.getDetails()).containsEntry("provider", "VNPay");
}
@Test
void whenPingThrows_thenStatusIsDownWithError() {
var indicator = new PaymentGatewayHealthIndicator(() -> {
throw new IllegalStateException("timeout");
});
Health health = indicator.health();
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
assertThat(health.getDetails().get("error").toString()).contains("timeout");
}
}
PaymentGatewayClient has only one method, so you pass a lambda instead of a mock.
6.2. Test Metrics
Micrometer provides SimpleMeterRegistry, which stores metrics in memory and is very convenient for unit tests:
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceTest {
@Test
void whenOrdersCreated_thenCounterIncrementsPerChannel() {
var registry = new SimpleMeterRegistry();
var service = new OrderService(registry);
var customer = new Customer(1L, "An");
service.createOrder(customer, "web");
service.createOrder(customer, "web");
service.createOrder(customer, "mobile");
assertThat(registry.get("orders.created").tag("channel", "web").counter().count())
.isEqualTo(2.0);
assertThat(registry.get("orders.created").tag("channel", "mobile").counter().count())
.isEqualTo(1.0);
}
}
If the metric doesn’t exist, registry.get(...) throws MeterNotFoundException. This way, the test also catches typos in metric names.
7. Security and Common Mistakes
Separate admin port. You can run Actuator on a separate port, only exposed within your internal network:
management.server.port=8081
In this case, the application serves on 8080, while /actuator/** is only on 8081. You block this port at the firewall or ingress. In Kubernetes, remember to change the port in your probe configuration to 8081.
Limit health details. On production, use show-details=when-authorized combined with Spring Security instead of always. Anonymous users then only see {"status":"UP"}.
Common mistakes:
- Calling
/actuator/metricsreturns404: you haven’t addedmetricstoexposure.include. - Health check fails randomly: an indicator makes network calls without a timeout, or its response time exceeds the probe’s timeout.
- Pod restarts continuously when the database is flaky: the database is in the liveness group.
- Memory gradually increases over time: a metric uses tags with unbounded value sets.
- You don’t see the
/actuator/prometheusendpoint: missingmicrometer-registry-prometheusor haven’t exposed the endpoint.
8. Conclusion
Spring Boot Actuator gives you health checks and metrics almost for free. Your job is to configure them correctly: expose only the endpoints you need, write HealthIndicator implementations with timeouts for important dependencies, and separate liveness from readiness so Kubernetes responds appropriately.
With metrics, Micrometer lets you record business Counter and Timer metrics in just a few lines, then export to Prometheus with a single dependency. Keep tag cardinality low and write tests using SimpleMeterRegistry to ensure metrics don’t silently break after each refactor.