Skip to main content

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.
PartWho it’s forWhat you get
1 — BeginnerYou’ve added the starter and want the mapThe three gates every Actuator request passes, the complete endpoint table, and the first thing to configure
2 — IntermediateYou’re about to expose Actuator in productionThe separate management port, EndpointRequest, chain ordering, and what actually leaks
3 — AdvancedYou own the pagerCustom 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.
Three gates decide whether /actuator/env answers 1. Discovery Actuator finds every @Endpoint bean on the classpath. 2. Access management.endpoint .<id>.access May it be operated? 3. Exposure .web.exposure .include Mapped onto HTTP? 4. Security Optional, and the only one that asks who you are. Defaults in Spring Boot 4.1.1 Gate 2 is unrestricted for every endpoint except heapdump and shutdown, which default to none. Gate 3 admits health and nothing else. Gate 4 does not exist until you add Spring Security and write a chain for it. A failure at gate 2 or 3 returns 404. A failure at gate 4 returns 401 or 403. Reading a 404 as “disabled” is the single most common Actuator misconception.
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.

What you get with zero configuration

Add the starter, configure nothing, and ask:
$ curl -s -u ops:ops-password http://localhost:8080/actuator
{
    "_links": {
        "self":        { "href": "http://localhost:8080/actuator" },
        "health":      { "href": "http://localhost:8080/actuator/health" },
        "health-path": { "href": "http://localhost:8080/actuator/health/{*path}", "templated": true }
    }
}

$ curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://localhost:8080/actuator/env
404

$ curl -s -u ops:ops-password http://localhost:8080/actuator/health
{
    "groups": ["liveness", "readiness"],
    "status": "DOWN"
}
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:
EndpointExposed by defaultaccess defaultNotes
healthyesunrestrictedthe only one, out of the box
infonounrestrictedBoot 4.1 added process.* fields
beansnounrestricted426 beans in the demo app, with types and wiring
conditionsnounrestrictedthe auto-configuration report
configpropsnounrestrictedvalues masked like env
envnounrestrictedmasks every value by default — see Part 2
loggersnounrestrictedhas a POST. A write endpoint
mappingsnounrestrictedevery URL your application serves
metricsnounrestrictedfrom spring-boot-micrometer-metrics
prometheusnounrestrictedneeds micrometer-registry-prometheus
threaddumpnounrestrictedtwo operations: JSON and text/plain
sbomnounrestrictedsoftware bill of materials
scheduledtasksnounrestricted
heapdumpnononeinclude: "*" is not enough
shutdownnononeinclude: "*" is not enough
startupnounrestrictedneeds a BufferingApplicationStartup
httpexchangesnounrestrictedneeds an HttpExchangeRepository bean
auditeventsnounrestrictedneeds an AuditEventRepository bean
logfilenounrestrictedneeds logging.file.name
caches, flyway, liquibase, quartz, sessions, integrationgraphnounrestrictedconditional on the relevant module being present
“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:
$ curl -s http://localhost:8080/actuator/env/spring.datasource.password
{
  "source": "Config resource 'class path resource [application.yaml]' ...",
  "value": "******"
}

$ curl -s http://localhost:8080/actuator/env/acme.partner.credential
{
  "source": "Config resource 'class path resource [application.yaml]' ...",
  "value": "******"
}
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:
acme.partner.credential          S3CRET-partner-credential
spring.datasource.password       not-a-real-password-but-watch-what-/actuator/env-does-with-it
spring.security.user.password    ops-password
So env is a configuration risk, not a default one. The endpoint that really hands over your secrets is a different one — and it is not exposed by "*":
$ curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/actuator/heapdump
404
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:
$ curl -s -o /tmp/heap.hprof -w 'status=%{http_code} bytes=%{size_download} type=%{content_type}' \
       http://localhost:8080/actuator/heapdump
status=200 bytes=59065395 type=application/octet-stream

$ strings /tmp/heap.hprof | grep -c 'S3CRET-partner-credential'
1

$ strings /tmp/heap.hprof | grep -o 'not-a-real-password[^"]*' | head -1
not-a-real-password-but-watch-what-/actuator/env-does-with-it
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:
$ curl -s -X POST -H 'Content-Type: application/json' \
       -d '{"configuredLevel":"TRACE"}' http://localhost:8080/actuator/loggers/org.springframework
  status=204

$ curl -s http://localhost:8080/actuator/loggers/org.springframework
{ "configuredLevel": "TRACE", "effectiveLevel": "TRACE" }
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.

The separate management port

management:
  server:
    port: 9001
    address: 127.0.0.1
  endpoints:
    web:
      base-path: /manage
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.

Match on EndpointRequest, never on a path string

@Configuration
public class SecuredActuatorConfig {

    @Bean
    @Order(1)
    SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests((requests) -> requests
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()
                .anyRequest().hasRole("ACTUATOR"))
            .httpBasic((basic) -> { })
            .csrf((csrf) -> csrf.disable())
            .sessionManagement((session) -> session.sessionCreationPolicy(STATELESS));
        return http.build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain applicationChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll())
            .csrf((csrf) -> csrf.disable());
        return http.build();
    }
}
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:
RequestAnonymousops / ROLE_ACTUATORWrong password
/actuator/health503503
/actuator/info200
/actuator/env401200401
/actuator/beans401200
/actuator/threaddump401200
/actuator (links index)401
/orders/count200
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:
management:
  endpoint:
    health:
      show-details: when-authorized
      roles: ACTUATOR
Anonymous callers get:
{"groups":["liveness","readiness"],"status":"DOWN"}
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:
Boot 3.5.16   org.springframework.boot.actuate.health.HealthIndicator      (spring-boot-actuator)
Boot 4.1.1    org.springframework.boot.health.contributor.HealthIndicator  (spring-boot-health)
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:
AdminClient admin = AdminClient.create(Map.of(BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"));
admin.describeCluster().nodes().get();
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:
PropertyDefaultWhat it bounds
request.timeout.ms30000one attempt
default.api.timeout.ms60000the whole call, retries included
socket.connection.setup.timeout.ms10000one TCP connect
retries2147483647
metadata.recovery.strategyrebootstrap
Why setting request.timeout.ms does not help 0s30s60s attempt 1 attempt 2 attempt 3 attempt 4 attempt 5 attempt n default.api.timeout.ms = 60000 — the only bound that actually fires request.timeout.ms = 30000 — bounds one attempt, then retries retries defaults to Integer.MAX_VALUE, so exhausting request.timeout.ms starts another attempt. The call returns when the API-level timeout fires, at 60 seconds, measured at 60.002. Fix: set all four bounds, plus an outer KafkaFuture.get(timeout). Measured result: 1.6 s instead of 60.2 s.
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 192 Rebootstrapping 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:
livenessProbe:  { httpGet: { path: /actuator/health, port: 8080 } }
readinessProbe: { httpGet: { path: /actuator/health, port: 8080 } }
One upstream outage, two wirings Both probes → /actuator/health third party returns 503 externalApi = DOWN /actuator/health = 503 liveness fails on every replica kubelet restarts the whole fleet a partial outage becomes a total one Probes → the matching groups third party returns 503 externalApi = DOWN /health/readiness = 503 /health/liveness = 200 instance leaves the Service process keeps running traffic returns when the upstream does
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.
management:
  endpoint:
    health:
      group:
        liveness:
          include: livenessState,diskSpace
        readiness:
          include: readinessState,ordersDatabase,externalApi
        startup:
          include: ordersDatabase
      probes:
        enabled: true
--- 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.
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 9001 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 9001 }
  periodSeconds: 5
  failureThreshold: 2
startupProbe:
  httpGet: { path: /actuator/health/startup, port: 9001 }
  periodSeconds: 5
  failureThreshold: 30
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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.