Spring Boot Actuator in Production: Every Endpoint, Securing It, and Custom Health Indicators
Every Actuator endpoint on Spring Boot 4.1.1, what each one leaks, and how to secure it — measured rather than described. /actuator/env masks every value by default and heapdump is not exposed by include:”*” at all, but turn it on and it hands over 59 MB containing the credentials env just masked. Plus the three gates a request passes, a separate management port, custom indicators for a database, Kafka and an external API — and the naive Kafka health check that blocks for exactly 60.002 seconds because request.timeout.ms is not the property that bounds it.
Add spring-boot-starter-actuator to a Spring Boot 4 application, start it, and ask what it publishes. The answer is one endpoint: /actuator/health. Ask it for /actuator/env and you get a 404 — not a 403, a 404, because that endpoint exists, is enabled, and simply was never mapped onto HTTP.
That gap between “exists” and “reachable” is where most Actuator mistakes live. People read the 404 as “it’s off”, set management.endpoints.web.exposure.include: "*" to make their metrics work, and ship an application whose entire bean graph, URL map and thread state is one unauthenticated GET away. Or they go the other way, wire a Kubernetes liveness probe to /actuator/health, add a health indicator that pings a third-party API, and discover months later that a vendor’s bad afternoon restarts every pod they own.
This article is the full picture, verified by running it: every endpoint and what it actually exposes by default, the two mechanisms that secure it, and how to write health indicators for a database, a Kafka cluster and an external HTTP API without turning your health endpoint into an outage amplifier.
Versions. Everything here was run against Spring Boot 4.1.1 (GA 20 August 2026), Spring Framework 7.0.9, Spring Security 7.1.1, Micrometer 1.17.1, kafka-clients 4.2.1 and H2 2.4.240, on Eclipse Temurin JDK 25.0.4.1+1 with Maven 3.9.11.
Every status code, JSON body, byte count and timing figure below comes from a file in the companion repository’s docs/output/, regenerated by a single ./scripts/run-all.sh. Repository: asmhatre/spring-boot-demo.
Part
Who it’s for
What you get
1 — Beginner
You’ve added the starter and want the map
The three gates every Actuator request passes, the complete endpoint table, and the first thing to configure
2 — Intermediate
You’re about to expose Actuator in production
The separate management port, EndpointRequest, chain ordering, and what actually leaks
3 — Advanced
You own the pager
Custom health indicators for DB, Kafka and HTTP; the 60-second check; groups that stop a restart cascade
The parts build on each other. The three-gate model in Part 1 is what makes the security rules in Part 2 make sense, and the reason one of them silently stops working. The health indicator you write in Part 3 is the one that turns the default /actuator/health red in Part 1.
Part 1 — The map
Three gates, not one
An Actuator endpoint is reachable over HTTP only if three independent things all say yes. Nearly every “why can’t I hit this” and every accidental exposure is one of these three being confused for another.
The distinction that costs people the most: exposure is not access control. Widening exposure decides what exists on HTTP. Deciding who may call it is gate 4, and it is a completely separate piece of configuration that you have to write.
Three things in that last response are worth stopping on.
The groups are there by default. The Spring Boot 4.0 migration guide records this as a change: the liveness and readiness probes are now enabled by default, so the health endpoint exposes both groups without being asked. Turn them off with management.endpoint.health.probes.enabled=false. Part 3 is largely about using them properly.
There are no details.management.endpoint.health.show-details defaults to never, so even an authenticated caller sees a bare status. Almost everyone flips this to always without asking who can reach the endpoint; Part 2 covers the third option that is nearly always the right one.
The status is DOWN. That is not a broken demo. This application registers a Kafka health indicator and there is no broker running. One custom indicator that touches a third party is all it takes to turn the default /actuator/health red — and that URL is where most Kubernetes manifests point their readiness and liveness probes. Hold that thought; it is the whole of Part 3.
Every endpoint
Read from the running application via a custom diagnostics endpoint rather than from documentation, so the access column reflects Spring Boot’s own spring-configuration-metadata.json:
“Endpoint X isn’t there” is usually gate 1, not gate 3.httpexchanges, startup, auditevents and logfile are all conditional on a bean or property existing. Setting include: "*" and still getting a 404 from /actuator/httpexchanges means you never declared an HttpExchangeRepository, not that exposure failed.
The one thing to configure first
Not exposure. Move Actuator off the application port:
management:
server:
port: 9001
address: 127.0.0.1
That single change means an ingress or load balancer forwarding to 8080 cannot reach Actuator at all, regardless of how the rest of your configuration evolves. Everything else in this article is defence behind that line. Part 2 shows what it does and does not isolate.
Part 2 — Securing it
What actually leaks
The received wisdom is that /actuator/env dumps your database password. On Spring Boot 4 it does not. With exposure set to "*", a permitAll security chain, and no credentials sent at all:
The second one is the interesting one. acme.partner.credential matches none of the classic password/secret/token/key patterns, and it is masked anyway. Masking is not key-name pattern matching — it is management.endpoint.env.show-values, which defaults to never. Set it to always and all three come back in the clear:
management.endpoint.heapdump.access defaults to none, and exposure does not override access. Reading every management.endpoint.*.access key out of Spring Boot’s own configuration metadata gives exactly two endpoints defaulting to none: heapdump and shutdown. Everything else is unrestricted.
Turn it on, though, and the story changes completely:
Fifty-nine megabytes containing, in plaintext, both properties that /actuator/env had just masked to ******.
Sanitisation is a rendering feature of one endpoint, not a security boundary. It controls what /actuator/env and /actuator/configprops print. It has no bearing on process memory, and heapdump serves process memory. If you ever set heapdump.access to unrestricted — and there are legitimate reasons to, temporarily — treat that endpoint as equivalent to shell access.
The other endpoint people underestimate is loggers, because it takes a POST:
204, no credentials. Whoever can do that has a denial-of-service primitive — TRACE on a busy service will fill a disk — and, depending on your logging configuration, a way to get request bodies and headers written somewhere they may be readable through another channel.
GET :8080/actuator/health 401 <- application chain; no Actuator on this port
GET :8080/orders/count 200
GET :9001/manage/health 503
GET :9001/actuator/health 404 <- base-path moved
GET :9001/orders/count 404 <- separate context; no application controllers
$ ss -ltn | grep -E ':(8080|9001)'
LISTEN 0 100 *:8080 *:*
LISTEN 0 100 [::ffff:127.0.0.1]:9001 *:*
Two facts are doing the work here. The management port runs a separate application context with its own DispatcherServlet, which is why :9001/orders/count is a 404 — it genuinely cannot serve your controllers. And binding to 127.0.0.1 (or, in Kubernetes, simply not listing 9001 as a Service port) makes Actuator unreachable from outside by routing rather than by an authorisation rule somebody has to keep correct through six months of refactoring.
This is defence in depth, not a replacement for authorisation. Anything in the same pod or on the same host still reaches 9001, and that includes a compromised sidecar or an SSRF bug in your own application. Which brings us to gate 4.
Three things in there are load-bearing.
The matcher. A rule written as requestMatchers("/actuator/**") works perfectly until somebody sets management.endpoints.web.base-path — as the previous section did. The endpoints move to /manage/**, the rule does not follow, and your Actuator is now unprotected with no error message anywhere. EndpointRequest asks the endpoint registry where things actually are, so it follows the configuration. This is the direct payoff of the gate model from Part 1: exposure and base path are gate 3, your security rule is gate 4, and a rule that hard-codes gate 3’s output is a rule with a hidden coupling.
The ordering. Without explicit @Order, whichever chain Spring registers first wins for a given request, and an application chain ending in permitAll() will happily swallow /actuator/**. If you want to understand exactly how that resolution works, I wrote up the full filter chain, in order, and how to debug it.
The stateless, CSRF-free configuration. Spring Boot’s auto-configured security is browser-shaped and turns CSRF protection on. Actuator is a machine-to-machine API, and POST /actuator/loggers/{name} from a script or a config-management tool will be rejected before it reaches the endpoint.
The fingerprint of this one is a 401 where you expected a 403. While building the companion repository, POST /stub/upstream/mode returned 401 while GET /orders/count with the same credentials returned 200. That combination reads as “my credentials are wrong” and sends you off to check the password. It was CSRF. If you have a GET that authenticates and a POST that does not, stop checking credentials and check csrf(). There is more on this in CORS, CSRF and SameSite in Spring Boot 4.
The resulting matrix
Exposure is still "*" throughout:
Request
Anonymous
ops / ROLE_ACTUATOR
Wrong password
/actuator/health
503
503
—
/actuator/info
200
—
—
/actuator/env
401
200
401
/actuator/beans
401
200
—
/actuator/threaddump
401
200
—
/actuator (links index)
401
—
—
/orders/count
200
—
—
Everything is exposed and nothing leaks. That is the shape you want: use exposure to decide what exists, and the security chain to decide who may use it.
show-details: when-authorized
The setting almost nobody uses and almost everybody should:
An authenticated ROLE_ACTUATOR caller gets the full nine-component breakdown, naming kafka as the one that is failing. Same URL, same status code, different body.
Your load balancer gets the signal it needs from the status code alone. Your on-call engineer gets the breakdown. An anonymous prober learns that something is unhealthy but not which of your dependencies to go and look at next — which is a meaningful difference when the failing component is named legacy-billing-oracle.
Part 3 — Custom health indicators, and the outage they cause
Where HealthIndicator went
Before any of this compiles on Boot 4, one import has to change:
org.springframework.boot.actuate.health does not exist in Boot 4.1.1 — not deprecated, absent. Health, Status, AbstractHealthIndicator and the composites all moved into a new spring-boot-health module. Two more that will bite:
The interface method was renamed. Boot 3 had default Health getHealth(boolean includeDetails); Boot 4 has default Health health(boolean includeDetails). If you overrode the old one it silently stops being an override — add @Override and let the compiler find it.
Health no longer extends HealthComponent. That class is gone from 4.1.1 entirely.
EndpointRequest moved from org.springframework.boot.actuate.autoconfigure.security.servlet to org.springframework.boot.security.autoconfigure.actuate.web.servlet.
One Boot 4 modularisation trap that is not about Actuator but will hit you writing these indicators.RestClient auto-configuration is no longer in the web starter. Adding spring-boot-starter-webmvc and injecting RestClient.Builder fails at startup with “required a bean of type org.springframework.web.client.RestClient$Builder that could not be found”. Add spring-boot-starter-restclient. This cost a startup cycle while building the companion repository.
Also: several migration write-ups claim Boot 4 replaces Actuator with a “Micrometer 2 observability stack”. Boot 4.1.1 resolves Micrometer 1.17.1, and maven-metadata.xml shows 1.17.1 as the newest GA with 1.18.0-M1 as a milestone. Metrics moved to their own module and there is a new OpenTelemetry starter; neither is a major Micrometer release.
Three indicators, three different jobs
import org.springframework.boot.health.contributor.AbstractHealthIndicator;
import org.springframework.boot.health.contributor.Health;
@Component("ordersDatabase")
public class OrdersDatabaseHealthIndicator extends AbstractHealthIndicator {
private final JdbcTemplate jdbc;
private final Duration slowThreshold = Duration.ofMillis(250);
public OrdersDatabaseHealthIndicator(JdbcTemplate jdbc) {
this.jdbc = jdbc;
this.jdbc.setQueryTimeout(2);
}
@Override
protected void doHealthCheck(Health.Builder builder) {
Instant start = Instant.now();
Integer count = this.jdbc.queryForObject("SELECT COUNT(*) FROM orders", Integer.class);
Duration took = Duration.between(start, Instant.now());
builder.status(took.compareTo(this.slowThreshold) > 0 ? "DEGRADED" : "UP")
.withDetail("orders", count)
.withDetail("queryMs", took.toMillis());
}
}
Prefer AbstractHealthIndicator over the bare interface: it catches your exceptions and turns them into DOWN with the error recorded, rather than letting one throwing indicator break the whole /actuator/health response.
Note what it checks. Boot’s built-in db indicator runs a validation query and answers “can I reach the database”, which is rarely the question that matters. This one runs the query the service actually depends on.
The bean name is the JSON key.@Component("ordersDatabase") produces "ordersDatabase" in the response; Boot strips a trailing HealthIndicator from the bean name, so ordersDatabaseHealthIndicator gives the same key — verified by registering a bean named suffixProbeHealthIndicator and watching it appear as suffixProbe. Rename the bean and every dashboard, alert rule and health-group include: that referenced the old key stops matching — silently, because a group that includes a name nothing provides is not an error.
Custom statuses like DEGRADED are legal, but an unmapped one aggregates as UNKNOWN and maps to HTTP 200. If you invent a status, also set management.endpoint.health.status.order and .http-mapping.
The 60-second health check
This is the most useful number in the whole exercise. Here is the textbook Kafka health check, the one that appears in every tutorial:
Point it at a broker that is not there and time it:
$ time curl -s http://localhost:8080/actuator/health/kafka
{
"details": {
"tuned": false,
"error": "TimeoutException: Timed out waiting for a node assignment. Call: listNodes",
"probeMs": 60002,
"budgetMs": 180000
},
"status": "DOWN"
}
wall clock: 60.2 s
Sixty point zero zero two seconds. The outer budget was 180 s, so it was not the binding constraint. Kafka’s own default was. These are the AdminClientConfig defaults, read out of AdminClientConfig.configDef() on kafka-clients 4.2.1:
Property
Default
What it bounds
request.timeout.ms
30000
one attempt
default.api.timeout.ms
60000
the whole call, retries included
socket.connection.setup.timeout.ms
10000
one TCP connect
retries
2147483647
—
metadata.recovery.strategy
rebootstrap
—
The trap is that request.timeout.ms is the property everyone sets. It bounds one attempt, and with retries at Integer.MAX_VALUE the client just attempts again until the API-level timeout fires. Setting the four bounds together, plus an outer KafkaFuture.get(timeout), gives:
{"details":{"tuned":true,"error":"TimeoutException: null","probeMs":1500,"budgetMs":1500},"status":"DOWN"}
wall clock: 1.6 s
Why this matters beyond the number: your probe interval is probably 10 seconds. A check that takes 60 means six probes are in flight at once, each holding a container worker thread, for as long as the broker is down. That is the mechanism by which a Kafka outage becomes an application outage. If you are running Spring Kafka, the companion pieces on producers, consumers and serialisation and error handling, DLT and retry topics cover the rest of that surface.
The rebootstrap storm. Kafka 4 defaults metadata.recovery.strategy to rebootstrap. A long-lived AdminClient pointed at a dead broker keeps a background thread retrying, and it logs. The first run of the companion repository produced 192Rebootstrapping with Cluster(id = null, ...) lines in a few seconds and then ran the sandbox out of memory. Set metadata.recovery.strategy: none and a sane reconnect.backoff.max.ms. Note that this thread is working whether or not anyone is calling your health endpoint.
The HTTP indicator has the same shape of problem with a blunter cause. SimpleClientHttpRequestFactory initialises both its connectTimeout and readTimeout fields to -1 and, seeing a negative value, never calls HttpURLConnection.setReadTimeout at all — so the JDK default applies, and that default is no timeout. Not a long one. None. With connect and read timeouts of 750 ms set explicitly, against a stub that sleeps 30 seconds:
{"details":{"error":"ResourceAccessException: I/O error on GET request ...: Read timed out",
"latencyMs":753,"timeoutMs":750},"status":"DOWN"}
wall clock: 0.84 s
latencyMs: 753 against a 750 ms budget. Remove that one line and it becomes 30 seconds, holding a Tomcat worker throughout.
The outage this all causes
Now the part that matters most, and it is not about any individual indicator.
A third-party API goes down. Your externalApi indicator reports DOWN. /actuator/health aggregates to DOWN and answers 503. And your Kubernetes manifest, like most, points both probes at it:
The readiness failure is correct — this instance cannot serve. The liveness failure is a catastrophe: the kubelet kills the container, every replica fails the same probe at the same moment, and the deployment enters a restart loop. The restarts make recovery slower, because every fresh JVM has to warm up while the upstream is still failing.
Liveness answers “is this process broken beyond recovery?” Almost nothing external belongs in it, because restarting cannot fix an external problem. Readiness answers “should this instance receive traffic right now?” Dependencies belong there.
--- baseline: upstream UP ---
GET /actuator/health HTTP 503
GET /actuator/health/liveness HTTP 200
GET /actuator/health/readiness HTTP 200
--- upstream DOWN ---
GET /actuator/health HTTP 503
GET /actuator/health/liveness HTTP 200
GET /actuator/health/readiness HTTP 503
Liveness held at 200 across the outage. Readiness moved. Kubernetes removes the instance from the Service and leaves the process alone; when the upstream recovers, readiness goes green and traffic returns with no restarts at all.
Notice that the aggregate /actuator/health was 503 in both columns — the Kafka indicator is down throughout, since that environment runs no broker. That is precisely why you should never point a probe at the aggregate. It is the union of everything, and it tells the orchestrator nothing actionable.
Port 9001 is the management port from Part 2. The kubelet reaches it inside the pod network; the ingress does not. And note the startupProbe: without one, a JVM that takes 45 seconds to warm up under failureThreshold: 3 and periodSeconds: 10 gets killed at 30 seconds, forever, and the symptom is a crash-loop with nothing in the logs.
Which dependencies belong in readiness? Ask: if this is down, can this instance still serve any useful request? Primary database — yes, readiness. Kafka, for a service whose only job is consuming — yes. Kafka, for a service that also serves reads — probably not; degrade instead of withdrawing. A recommendations API you fall back to a static list for — no, and do not write a health indicator for it at all.
Every dependency in readiness is a dependency that can take your service out of rotation. That list should be shorter than your instinct suggests.
The long tail
Things worth knowing that this article cannot afford to expand, each with the chapter that reproduces it:
Print the live endpoint registry instead of predicting it — a custom @Endpoint(id = "diag") that dumps every mapped operation and every registered health contributor, so you can see that loggers has a POST and threaddump has two operations. docs/08
Graceful shutdown and readinessState — Boot flips it to REFUSING_TRAFFIC before the server stops accepting, and you can drive it yourself with an AvailabilityChangeEvent to drain an instance before a risky migration. docs/07
management.endpoint.health.cache.time-to-live — real, useful, and a trap if you reach for it before fixing timeouts: it gives you a fast endpoint serving a stale answer. docs/06
Reuse the AdminClient — creating one per probe opens fresh broker connections every interval; across a fleet that is a denial-of-service against your own brokers. docs/05
Make an authentication failure distinguishable from an outage — an indicator that forgets its credentials reports 401 and looks exactly like the upstream being down. docs/05
Test the surprises, not the happy path — assert that heapdump stays 404 under wildcard exposure, that anonymous health carries no components, and that an upstream outage does not move livenessState. docs/09
@SpringBootTest needs DEFINED_PORT if an indicator makes a real HTTP call; under the default MOCK environment it reports DOWN with a connection error and your assertions prove nothing. docs/09
Should you build any of this?
Mostly, no — and that is the honest answer. Spring Boot already ships indicators for your DataSource, disk space, SSL certificates and the availability states. For a great many services the correct amount of custom health-indicator code is zero, and the correct amount of Actuator configuration is three lines: a separate management port, show-details: when-authorized, and probes wired to the liveness and readiness groups instead of the aggregate.
Write a custom indicator when it will change an operational decision — when a red light there means the orchestrator should stop sending this instance traffic. If it would only make a dashboard more colourful, you want a metric, not a health indicator. Metrics are free to be slow, stale and wrong; health checks run on every instance on every probe interval forever, and each one is a new way for your service to take itself down.
The strongest version of this rule: every entry in your readiness group should be something you would be willing to have page you at 3am. If you would not, it does not belong there.
No Comments yet!