Here is the English translation of the article body:


Once you add Actuator and /actuator/health starts returning UP, the next thing you’ll want is to “surface the liveness of my own services and legacy APIs here too.” The standard health only looks at built-in indicators such as DataSource, so the state of external dependencies isn’t reflected out of the box.

This article covers everything from implementing a custom HealthIndicator that represents the liveness of external dependencies, to assigning it to readiness/liveness, and even how to build your own operational endpoint using @Endpoint. Introducing Actuator itself and configuring the exposure of the standard endpoints is left to the Getting Started with Spring Boot Actuator article, so here we’ll focus on going “one step further.”

When the standard health isn’t enough

When running on Kubernetes, you’ll want the readinessProbe to reflect “whether this app is truly in a state to handle requests.” But if the app depends on an external payment API or an internal legacy service, their liveness won’t show up in the standard health.

In other words, you end up with a mismatch where the dependency is down but the Pod stays UP. A custom HealthIndicator is what fills this gap. Here are the three things we’ll build in this article:

  • A custom HealthIndicator that returns the liveness of external dependencies
  • health groups that route it to readiness/liveness
  • A custom @Endpoint that lets you trigger operational actions

HealthIndicator basics

Let’s start with the minimal setup. You just implement HealthIndicator, override health(), assemble a Health object, and return it.

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class LegacyServiceHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        boolean ok = checkLegacyService();
        if (ok) {
            return Health.up()
                    .withDetail("service", "legacy-billing")
                    .build();
        }
        return Health.down()
                .withDetail("service", "legacy-billing")
                .build();
    }

    private boolean checkLegacyService() {
        // Write the actual connectivity check here
        return true;
    }
}

In addition to Health.up() / Health.down(), when you want to represent an arbitrary status you can write something like Health.status("OUT_OF_SERVICE"). The information you attach with withDetail() is included in the response when detail display is enabled. Put in values that will help with root-cause investigation.

Another important point is the naming convention. The portion of the Bean name with HealthIndicator removed becomes the key on the endpoint. In the example above, it shows up as legacyService.

{
  "status": "UP",
  "components": {
    "legacyService": {
      "status": "UP",
      "details": { "service": "legacy-billing" }
    }
  }
}

Checking the liveness of external dependencies safely

In production, you need to be careful that the connectivity check itself doesn’t destabilize health. In particular, it’s important to explicitly set short timeouts. If the other side responds slowly, the entire health gets dragged down with it.

For external APIs, use a lightweight ping or HEAD request if possible. Always catch exceptions and convert them into down(e) so the cause is preserved.

@Component
public class PaymentApiHealthIndicator implements HealthIndicator {

    private final RestClient restClient;

    public PaymentApiHealthIndicator(RestClient.Builder builder) {
        ClientHttpRequestFactorySettings settings =
                ClientHttpRequestFactorySettings.defaults()
                        .withConnectTimeout(Duration.ofSeconds(1))
                        .withReadTimeout(Duration.ofSeconds(2));
        this.restClient = builder
                .baseUrl("https://payment.example.com")
                .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
                .build();
    }

    @Override
    public Health health() {
        try {
            restClient.get().uri("/ping").retrieve().toBodilessEntity();
            return Health.up().withDetail("target", "payment-api").build();
        } catch (Exception e) {
            return Health.down(e).withDetail("target", "payment-api").build();
        }
    }
}

If the check is expensive, consider techniques such as caching the result for a few seconds instead of hitting the dependency every time, or returning a result that is refreshed periodically in the background. Health is called at high frequency by monitoring and probes, so if it becomes a bottleneck, you’ve defeated the purpose.

Assigning custom indicators to readiness/liveness

On Kubernetes, readiness and liveness mean different things. Readiness is “can it receive traffic,” and liveness is “is the process alive.” The basic rule is to lean toward readiness for the liveness of external dependencies.

Spring Boot lets you express this with a mechanism called health groups. Specify the indicators to include per group in application.yml.

management:
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      group:
        readiness:
          include: readinessState,paymentApi,legacyService
        liveness:
          include: livenessState
  endpoints:
    web:
      exposure:
        include: health,info

With this, your custom indicators are reflected in /actuator/health/readiness, while /actuator/health/liveness stays minimal. If you point the Kubernetes readinessProbe at this path, the Pod is automatically removed from traffic when an external dependency goes down. For the details of the probe-side YAML, see the Kubernetes Deployment Guide.

An anti-pattern to watch out for here is putting external dependencies into liveness. If liveness turns DOWN just because an external API is temporarily down, Kubernetes will consider the process unhealthy and restart it. You’ll fall into a restart loop until the dependency recovers, so put external dependencies into readiness only.

Controlling detail display and security

show-details has three options: never / when-authorized / always. The default is never, which shows no details. In production, setting it to when-authorized and showing details only to authorized users is the safe choice.

What you need to be careful about is the content of withDetail(). If you put in full connection URLs, authentication tokens, or internal hostnames, they’ll be fully exposed to anyone who can see the details. Keep it to the minimum information needed for diagnosis. Since the concrete setup of authorization is in the realm of Security, we’ll just cover the policy here.

Building custom endpoints with @Endpoint

Beyond health, you can also build your own operational endpoints. For example, actions like “I want to see the cache state” or “I want to toggle a specific flag on/off.” You attach an ID with @Endpoint and annotate each operation.

import org.springframework.boot.actuate.endpoint.annotation.*;
import org.springframework.stereotype.Component;

@Component
@Endpoint(id = "features")
public class FeatureToggleEndpoint {

    private final Map<String, Boolean> toggles = new ConcurrentHashMap<>();

    @ReadOperation
    public Map<String, Boolean> features() {
        return toggles;
    }

    @ReadOperation
    public Boolean feature(@Selector String name) {
        return toggles.getOrDefault(name, false);
    }

    @WriteOperation
    public void setFeature(@Selector String name, boolean enabled) {
        toggles.put(name, enabled);
    }

    @DeleteOperation
    public void removeFeature(@Selector String name) {
        toggles.remove(name);
    }
}

@ReadOperation maps to GET, @WriteOperation to POST, and @DeleteOperation to DELETE. A parameter annotated with @Selector is received as part of the path (/actuator/features/{name}).

The endpoint you create won’t be visible from the outside unless you explicitly add it to the exposure configuration.

management:
  endpoints:
    web:
      exposure:
        include: health,info,features

Since this is an endpoint that can write operational actions, be especially careful about its exposure scope. It’s a good idea to consider it together with isolating your internal network or the management port.

Verifying behavior with curl

Once implemented, actually hit it to verify. First, let’s look at the readiness group.

curl -s localhost:8080/actuator/health/readiness | jq
{
  "status": "UP",
  "components": {
    "paymentApi": { "status": "UP", "details": { "target": "payment-api" } },
    "legacyService": { "status": "UP", "details": { "service": "legacy-billing" } }
  }
}

If you deliberately stop the external API, paymentApi turns DOWN, and the status of the entire group changes to DOWN as well. This lets you confirm whether the readinessProbe reacts as expected. Let’s hit the custom endpoint too.

curl -X POST localhost:8080/actuator/features/new-checkout \
  -H 'Content-Type: application/json' -d '{"enabled": true}'

curl -s localhost:8080/actuator/features | jq
# => { "new-checkout": true }

If the endpoint returns a 404, first check whether you forgot to add it to exposure.include and whether the ID spelling matches. If an indicator isn’t reflected in a group, review whether the name you wrote in include matches the Bean naming convention (the xxx in xxxHealthIndicator).

Summary

We’ve walked through the flow of representing the liveness of external dependencies with a custom HealthIndicator, routing them to readiness/liveness with health groups, and adding operational actions with @Endpoint. The state of dependencies that wasn’t visible in the standard health now properly rides on probes and monitoring.

From here, combining this with observability using Micrometer and Prometheus, which lets you track state as numbers over time, or with graceful shutdown, which makes deployment switchovers safe, will make your operations much more stable. When implementing, just don’t forget to keep the cost of the checks and the security of detail display in mind.