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
Why does /actuator only show the health link?
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.