Skip to content
CalliCoder

Spring Boot Actuator: Health Checks, Metrics, Endpoints

Published Updated Spring Boot 13 min read

What each Actuator endpoint is actually for, the difference between enabling and exposing one, health groups for Kubernetes probes, and why /actuator/env on a public port is a credential leak.

Spring Boot Actuator adds a set of HTTP endpoints that report on a running application: whether it is healthy, what it is configured with, how much heap it is using, which log levels are active. It takes one dependency to add and is the fastest way to make an application operable.

It is also the fastest way to publish your database password. Both facts are worth understanding before this reaches an environment that matters.

Written against Spring Boot 3.2 and Java 17.

One dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

That is the whole installation. Restart and /actuator responds with a discovery document listing what is reachable:

$ curl -s localhost:8080/actuator | jq
{
  "_links": {
    "self":   { "href": "http://localhost:8080/actuator" },
    "health": { "href": "http://localhost:8080/actuator/health" }
  }
}

One endpoint. This surprises people who expected a control panel, and it is the correct default.

Enabled is not exposed

Actuator has two independent switches per endpoint, and almost every configuration mistake comes from treating them as one.

Enabled means the endpoint bean exists. Nearly all of them are enabled by default (shutdown is the exception).

Exposed means it is reachable over HTTP. Only health is exposed by default.

So an endpoint you can see in the documentation and cannot reach is not disabled. It is unexposed:

# reachable over HTTP
management.endpoints.web.exposure.include=health,info,metrics,loggers,prometheus

# or everything, minus the ones you really do not want
management.endpoints.web.exposure.include=*
management.endpoints.web.exposure.exclude=env,heapdump,threaddump

# the bean itself does not exist
management.endpoint.shutdown.enabled=false

include=* in a properties file needs no quoting; in YAML it does, include: "*", because a bare asterisk is not valid YAML.

/health, and the part that matters in production

$ curl -s localhost:8080/actuator/health
{"status":"UP"}

That is the default: a single word, deliberately. Details are withheld because the endpoint is often the one thing reachable without authentication, and “which of my four databases is down” is information worth withholding from strangers.

management.endpoint.health.show-details=when-authorized

With details on, the composite becomes visible, and it is a composite. Spring registers a health indicator for every infrastructure component it can detect: db runs a validation query, diskSpace checks free bytes against a threshold, plus Redis, RabbitMQ, Mongo, Elasticsearch and others when those starters are present.

The aggregate is pessimistic: one indicator reporting DOWN makes the whole endpoint DOWN and returns HTTP 503. That is usually right and occasionally disastrous, a cache being unreachable should probably not take a service out of a load balancer if the service can serve from the database.

Health groups

This is the feature worth knowing. A group is a named subset of indicators with its own URL, which is exactly what container orchestration needs:

management.endpoint.health.group.liveness.include=ping
management.endpoint.health.group.readiness.include=db,diskSpace
management.endpoint.health.group.readiness.show-details=never
$ curl -s localhost:8080/actuator/health/readiness
{"status":"UP"}

The distinction is not cosmetic. Liveness answers “should this container be killed and restarted”, a database outage is not a reason to restart your process, and wiring a restart to it produces a crash loop across every replica at once. Readiness answers “should traffic be sent here”, where a database outage genuinely is a reason to stop. Point them at different groups.

Spring Boot also ships /actuator/health/liveness and /actuator/health/readiness automatically when it detects Kubernetes, backed by application lifecycle state rather than infrastructure checks.

A custom indicator

@Component
public class SearchIndexHealthIndicator implements HealthIndicator {

    private final SearchClient client;

    public SearchIndexHealthIndicator(SearchClient client) {
        this.client = client;
    }

    @Override
    public Health health() {
        try {
            long lagSeconds = client.replicationLagSeconds();
            if (lagSeconds > 300) {
                return Health.down()
                        .withDetail("replicationLagSeconds", lagSeconds)
                        .build();
            }
            return Health.up().withDetail("replicationLagSeconds", lagSeconds).build();
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

The bean name decides the key: SearchIndexHealthIndicator appears as searchIndex. Keep the check fast and give it a timeout, a health endpoint that blocks for thirty seconds is worse than no health endpoint, because the orchestrator’s probe times out and the container is killed while perfectly able to serve.

/metrics

$ curl -s localhost:8080/actuator/metrics/jvm.memory.used | jq
{
  "name": "jvm.memory.used",
  "measurements": [ { "statistic": "VALUE", "value": 5.7148e7 } ],
  "availableTags": [
    { "tag": "area", "values": ["heap", "nonheap"] },
    { "tag": "id",   "values": ["G1 Eden Space", "Metaspace", "..."] }
  ]
}

/actuator/metrics lists meter names; append one to read it; add ?tag=area:heap to filter. This is a debugging tool for confirming an instrument exists and is recording. It is not a monitoring system. There is no history and no aggregation. For that, add the Prometheus registry and scrape it.

/loggers

The endpoint that earns its place during an incident. Read a level:

$ curl -s localhost:8080/actuator/loggers/com.example.notes | jq
{ "configuredLevel": null, "effectiveLevel": "INFO" }

Change it, on a running process, with no restart and no deployment:

$ curl -X POST localhost:8080/actuator/loggers/com.example.notes \
    -H 'Content-Type: application/json' \
    -d '{"configuredLevel":"DEBUG"}'

Send {"configuredLevel": null} to revert to inherited. Being able to turn on debug logging for one package for ninety seconds, on one instance, is worth more than most dashboards. It requires include=loggers and it is a write operation, protect it.

/info

Empty until you fill it, and the useful filling is build metadata:

management.info.env.enabled=true
management.endpoints.web.exposure.include=health,info
info.app.name=Notes API

With the Maven plugin’s build-info goal (or Gradle’s springBoot { buildInfo() }), Spring reads META-INF/build-info.properties and reports version and build time. Add the Git plugin and git.commit.id appears too. “Which commit is actually running in staging” is a question worth answering with a URL rather than a guess.

Securing all of this

/actuator/env prints resolved configuration. /actuator/configprops prints bound configuration properties. Spring masks values whose keys look sensitive (anything matching password, secret, key, token, credentials) but that heuristic is a convenience, not a guarantee, and it knows nothing about your externalApiClientIdentifier. /actuator/heapdump downloads the heap, which contains every string in memory, including the ones the masker hid.

Two defences, and using both is normal:

A separate port, kept off the public load balancer:

management.server.port=8081
management.server.address=127.0.0.1

Authentication, using the request matcher Actuator provides:

@Bean
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher(EndpointRequest.toAnyEndpoint())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
            .anyRequest().hasRole("OPS"))
        .httpBasic(Customizer.withDefaults())
        .build();
}

EndpointRequest.toAnyEndpoint() follows the base path automatically, so this keeps working if someone changes management.endpoints.web.base-path. A hand-written /actuator/** matcher does not.

Frequently asked questions

Because only health is exposed over HTTP by default. The other endpoints exist; add them to management.endpoints.web.exposure.include.

What is the difference between enabling and exposing an endpoint?

Enabling controls whether the bean exists at all; exposing controls whether it is served over HTTP. Almost every “endpoint not found” is an exposure setting, not an enablement one.

Why is /actuator/health returning 503?

One health indicator is reporting DOWN and the aggregate is pessimistic. Turn on show-details=when-authorized and read which one, then decide whether that component should really be able to fail the whole check.

Should liveness and readiness use the same endpoint?

No, a database outage should stop traffic (readiness) but must not restart the container (liveness). Wiring a restart to an external dependency produces a synchronised crash loop. Use health groups to separate them.

How do I change a log level without redeploying?

POST to /actuator/loggers/<logger-name> with {"configuredLevel":"DEBUG"}. Send null to revert. The loggers endpoint must be exposed and should be authenticated.

Is it safe to expose /actuator/env?

No. It prints resolved configuration, and the masking of sensitive keys is pattern-based. It will not recognise your own naming. Treat env, configprops, heapdump and threaddump as internal-only.

Does Actuator slow the application down?

The endpoints cost nothing when not called. The instrumentation behind /metrics has a small constant overhead per HTTP request and per JDBC connection, well below anything measurable in ordinary use.

Why is /actuator/info empty?

It has no default content. Set management.info.env.enabled=true plus info.* properties, and generate build-info.properties with the build plugin for version and commit data.

Can I change the /actuator base path?

Yes, management.endpoints.web.base-path=/internal. Use EndpointRequest matchers in your security configuration so the rules follow the change.

How do I disable an endpoint completely?

management.endpoint.<name>.enabled=false removes the bean. Excluding it from exposure is enough to make it unreachable over HTTP, but disabling is the stronger statement.

Where should I go next?

Prometheus and Grafana turns the /metrics snapshot into history and dashboards, which is where Actuator stops being a debugging aid and starts being monitoring.