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 like env, heapdump, or configprops can 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 the HealthIndicator suffix, so the key in JSON is paymentGateway.
  • withDetail(...) adds supplementary information, displayed only when show-details allows it.
  • Health.down(e) automatically includes the error message in details.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/health to 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 like channel, status, or region.

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/metrics returns 404: you haven’t added metrics to exposure.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/prometheus endpoint: missing micrometer-registry-prometheus or 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.

spring-bootactuatormicrometermonitoringprometheus