10-production-engineering-observability

Health Checks and Spring Boot Actuator in Production

A staff-engineer guide to Spring Boot Actuator, custom health indicators, and Kubernetes liveness/readiness probe semantics.

August 14, 2026
backend-engineeractuatorhealthreadinesslivenesskubernetes-probes

Health Checks and Spring Boot Actuator

A service that is running is not the same as a service that is healthy. A JVM process can be alive — accepting TCP connections, responding to /actuator/info — while its connection pool is exhausted, its downstream dependency is down, or its event loop is wedged behind a slow query. Health checks are how an orchestrator (Kubernetes, ECS, a load balancer) learns the difference between "the process exists" and "the process can do useful work." Get them wrong and you either take healthy pods out of rotation during a routine GC pause, or you keep broken pods in rotation until customers notice. This guide covers Spring Boot Actuator end to end: what it exposes, how to build correct custom health indicators, and how liveness and readiness probes actually behave in Kubernetes.


1. What Actuator Gives You

Spring Boot Actuator is a production-readiness module: a set of HTTP (and JMX) endpoints that expose the internal state of a running application — health, metrics, environment, thread dumps, loggers, and more — without you writing any of that plumbing yourself.

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

Adding the starter alone exposes almost nothing over HTTP by default — only /actuator/health is enabled out of the box, and it returns a minimal {"status":"UP"} unless you opt into more detail. Everything else is opt-in, which is the correct default: an unauthenticated endpoint that dumps environment variables or a full thread dump is a real disclosure risk.

properties
# application.properties
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
management.endpoint.health.show-components=always
EndpointPurposeTypical exposure
/actuator/healthAggregated up/down status, optionally with component detailPublic (probes), detail restricted
/actuator/infoStatic build/git metadataPublic
/actuator/metricsAd-hoc metric browsing (raw values, not scrape format)Internal only
/actuator/prometheusPrometheus-format metrics scrape endpoint (needs micrometer-registry-prometheus)Internal / scraped by Prometheus only
/actuator/envResolved configuration propertiesNever public — leaks secrets unless sanitized
/actuator/loggersLive log-level changesInternal, authenticated
/actuator/threaddump, /actuator/heapdumpDiagnosticsInternal, authenticated, and heavy
🚨

Never set management.endpoints.web.exposure.include=* on an internet-facing application. /actuator/env, /actuator/heapdump, and /actuator/shutdown (if you've ever enabled it) are catastrophic in the wrong hands. Enumerate exactly what you expose.


2. Anatomy of /actuator/health

Actuator health is built from HealthIndicator beans. Spring Boot auto-registers indicators for the infrastructure it detects on your classpath — a DataSource gets a db indicator, a RedisConnectionFactory gets a redis indicator, a configured DiskSpaceHealthIndicator checks free disk space, and so on. Each indicator reports a Status (UP, DOWN, OUT_OF_SERVICE, UNKNOWN) plus optional detail. The top-level status is the aggregation of every registered indicator, driven by a StatusAggregator that — by default — reports the worst status present.

A fully detailed response looks like this:

json
{
  "status": "DOWN",
  "components": {
    "db": {
      "status": "UP",
      "details": { "database": "PostgreSQL", "validationQuery": "isValid()" }
    },
    "diskSpace": {
      "status": "UP",
      "details": { "total": 250685575168, "free": 116223832064, "threshold": 10485760 }
    },
    "paymentGateway": {
      "status": "DOWN",
      "details": { "reason": "Connection timed out after 2000ms" }
    }
  }
}
⚠️

show-details and show-components default to never for unauthenticated requests as of Spring Boot 2.6+. If you see {"status":"UP"} with no components in production but full detail locally, that's Spring Security or the show-details policy doing its job — not a bug. Decide deliberately who gets to see component-level detail (usually: internal network only, or an authenticated actuator role).


3. Writing Custom Health Indicators

Auto-configured indicators cover generic infrastructure. Your business-specific dependencies — a downstream payment gateway, a message broker consumer lag, a feature-flag service — need custom indicators.

java
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
 
    private final PaymentGatewayClient client;
 
    public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
        this.client = client;
    }
 
    @Override
    public Health health() {
        try {
            PingResult result = client.pingWithTimeout(Duration.ofMillis(500));
            if (result.isHealthy()) {
                return Health.up()
                    .withDetail("latencyMs", result.latencyMs())
                    .build();
            }
            return Health.down()
                    .withDetail("reason", result.reason())
                    .build();
        } catch (TimeoutException e) {
            // Timeout is a DOWN signal, not an exception that crashes the health endpoint
            return Health.down(e).withDetail("reason", "timeout").build();
        }
    }
}
🚨

The single most common Actuator bug: a custom HealthIndicator that makes a slow, unbounded network call to a downstream dependency. If that dependency hangs, every health check hangs, the readiness probe times out, Kubernetes marks the pod not-ready, and — if enough pods share the same failing dependency — your entire fleet gets pulled from rotation simultaneously. Always bound health-check calls with an aggressive timeout (hundreds of milliseconds, not seconds), and prefer checking a locally cached circuit-breaker state over making a live call on every probe.

Reactive health indicators

For WebFlux applications, implement ReactiveHealthIndicator instead so the check doesn't block a Netty event-loop thread:

java
@Component
public class InventoryServiceHealthIndicator implements ReactiveHealthIndicator {
 
    private final WebClient webClient;
 
    @Override
    public Mono<Health> health() {
        return webClient.get()
                .uri("/internal/ping")
                .retrieve()
                .toBodilessEntity()
                .timeout(Duration.ofMillis(400))
                .map(response -> Health.up().build())
                .onErrorResume(ex -> Mono.just(Health.down(ex).build()));
    }
}

Grouping indicators

You rarely want every indicator to gate every consumer the same way. Actuator supports health groups so liveness, readiness, and a human dashboard can each see a different subset:

properties
management.endpoint.health.group.readiness.include=readinessState,db,redis,paymentGateway
management.endpoint.health.group.liveness.include=livenessState
management.endpoint.health.group.readiness.show-details=always

4. Liveness vs Readiness: Why They're Different Questions

This is the concept engineers most often get wrong, because "health check" sounds like one question when it's really two:

ProbeQuestion it answersFailure actionWhat should feed it
Liveness"Is this process fundamentally broken and unrecoverable?"Kill and restart the containerDeadlock detection, fatal internal state — almost nothing else
Readiness"Can this instance currently serve traffic well?"Remove from load balancer rotation, do not restartDownstream dependency health, connection pool saturation, startup completion, graceful-shutdown draining
🚨

The classic production incident: an engineer wires the database health check into liveness. The database has a brief blip — a failover, a network partition — every pod's liveness probe fails simultaneously, Kubernetes restarts every pod at once, and now you have a thundering herd of reconnecting pods hitting a database that just recovered from a failover. The database health check belongs in readiness, not liveness. A dead database does not mean your JVM process is broken — it means it temporarily can't do useful work, which is exactly what readiness is for.

Enabling probe-specific health groups

Spring Boot has first-class support for the liveness/readiness split via the ApplicationAvailability infrastructure:

properties
management.endpoint.health.probes.enabled=true
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true

This exposes /actuator/health/liveness and /actuator/health/readiness automatically, backed by Spring's internal LivenessState and ReadinessState. On Kubernetes, Spring Boot detects the platform and enables this automatically when it sees Kubernetes service-account environment variables — but pinning it explicitly is safer and makes local Docker testing consistent with production.

Wiring the Kubernetes manifest

yaml
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 20
  periodSeconds: 10
  failureThreshold: 3
  timeoutSeconds: 2
 
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 2
  timeoutSeconds: 2
 
startupProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  failureThreshold: 30
  periodSeconds: 2

Use a startup probe for anything with a nontrivial boot time (Spring context refresh, JIT warmup, cache preload). Without it, a slow-starting pod can fail its liveness probe before it's even finished starting and get killed in a restart loop — failureThreshold: 30 at periodSeconds: 2 gives it up to 60 seconds to come up before liveness checks even begin.

Custom AvailabilityStateHealthIndicator participation

You can push custom signals into the readiness state directly, which is useful for graceful shutdown draining (covered in depth in the resilience guide):

java
@Component
public class DrainAwareReadiness {
 
    private final ApplicationAvailability availability;
    private final ApplicationEventPublisher events;
 
    public DrainAwareReadiness(ApplicationAvailability availability,
                                ApplicationEventPublisher events) {
        this.availability = availability;
        this.events = events;
    }
 
    public void beginDraining() {
        // Flips readiness to REFUSING_TRAFFIC — probe now returns 503,
        // pod is pulled from the Service before the container is killed
        events.publishEvent(new AvailabilityChangeEvent<>(this, ReadinessState.REFUSING_TRAFFIC));
    }
}

5. The /info Endpoint

/actuator/info is static — it surfaces build metadata that's baked in at build time, not computed per request. It's cheap, cacheable, and genuinely useful for "which version is actually deployed" questions during an incident.

xml
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>build-info</goal>
            </goals>
        </execution>
    </executions>
</plugin>
properties
management.info.env.enabled=true
management.info.git.mode=full
management.info.build.enabled=true
info.app.name=@project.name@
info.app.description=@project.description@
json
{
  "app": { "name": "order-service", "description": "Order processing API" },
  "build": { "version": "3.4.1", "artifact": "order-service", "time": "2026-08-10T09:12:41Z" },
  "git": { "commit": { "id": "a1b2c3d", "time": "2026-08-10T08:55:00Z" }, "branch": "main" }
}

Wire /actuator/info's git.commit.id into your deploy dashboard and your alert templates. "Which commit is actually running in prod-us-east-1?" is one of the most common questions during an incident, and it should be a 200ms curl, not a Slack thread.


6. A Preview of /metrics

/actuator/metrics lets you browse individual metric values ad hoc (/actuator/metrics/jvm.memory.used), which is handy for local debugging but not what production monitoring consumes. Production monitoring scrapes /actuator/prometheus (via micrometer-registry-prometheus) on an interval and stores the time series in Prometheus or a managed equivalent. The full mechanics of Micrometer, tagged metrics, and Prometheus scraping are covered in depth in the observability and monitoring guides in this phase — the point here is just that Actuator is the transport, not the metrics engine itself.

bash
curl -s localhost:8080/actuator/metrics/http.server.requests | jq .
curl -s localhost:8080/actuator/prometheus | grep http_server_requests_seconds_count | head -5

7. Securing Actuator Endpoints

Actuator endpoints are an attack surface. The two viable production strategies are network isolation and authentication — most mature setups use both.

Strategy 1: separate management port

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

Binding the management port to loopback (or a private interface) means /actuator/* is unreachable from outside the pod's network namespace entirely — only a sidecar or the kubelet on the same node can reach it. This is the cleanest approach in Kubernetes: probes hit 127.0.0.1:8081 from inside the pod's network namespace, while the public port 8080 never exposes Actuator at all.

Strategy 2: Spring Security role-gating

java
@Configuration
public class ActuatorSecurityConfig {
 
    @Bean
    SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll()
                .anyRequest().hasRole("ACTUATOR_ADMIN"))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}
ApproachProsCons
Separate management port + loopback bindNo auth needed, simplest, hard to misconfigureRequires infra awareness (probes must target the right port)
Spring Security role gatingFine-grained per-endpoint control, works on one portMore moving parts, easy to leave a hole (permitAll() typo)
API gateway / ingress rule blocking /actuator/*Centralized, no app changesRelies on gateway config discipline, doesn't stop intra-cluster access
⚠️

Use all three layers where you can. A separate management port stops external exposure even if someone fat-fingers an ingress rule; Spring Security stops it even if the port separation gets misconfigured in a refactor. Defense in depth applies to observability endpoints exactly as much as it applies to business APIs.


Key takeaways

  • Health checks answer two different questions — liveness ("restart me") and readiness ("route to me") — and conflating them causes cascading restart storms during routine dependency blips.
  • Never let a custom HealthIndicator make an unbounded network call; bound it with an aggressive timeout or check cached circuit-breaker state instead.
  • show-details and show-components default to hidden for unauthenticated callers — that's a security default, not a bug to "fix" by opening everything.
  • Use health groups (management.endpoint.health.group.*) so liveness, readiness, and human dashboards each see the right subset of indicators.
  • Prefer a separate, loopback-bound management port for Actuator in Kubernetes — it's the simplest way to make the entire attack surface unreachable from outside the pod.
  • Always pair a livenessProbe with a startupProbe for services with nontrivial boot time, or you'll get restart-loop deaths on slow starts.
  • /actuator/env, /actuator/heapdump, and /actuator/shutdown must never be exposed publicly — audit management.endpoints.web.exposure.include explicitly rather than using *.

Interview Questions

  • What is the practical difference between a liveness probe and a readiness probe? What happens if you wire your database health check into liveness?
  • How does Spring Boot Actuator decide the overall status shown at /actuator/health?
  • Why should a custom HealthIndicator never make an unbounded network call?
  • How would you expose different health information to an internal dashboard versus a Kubernetes probe?
  • What's the difference between /actuator/metrics and /actuator/prometheus, and which one does production monitoring actually scrape?
  • Why does Spring Boot disable show-details by default for unauthenticated requests?
  • Describe two independent ways to secure Actuator endpoints in production, and why you might use both.
  • What is a startupProbe for, and why doesn't a livenessProbe alone cover slow-starting applications?
  • How would you implement a health indicator for a WebFlux (reactive) application without blocking an event-loop thread?
  • What is ReadinessState.REFUSING_TRAFFIC used for, and how does it relate to graceful shutdown?
  • If a downstream payment gateway goes down, should that affect your service's readiness, liveness, both, or neither? Justify your answer.
  • What information belongs in /actuator/info, and why is it computed at build time rather than per-request?
  • What's a realistic Kubernetes failureThreshold/periodSeconds combination for a readiness probe on a service with a strict SLA, and what trade-off are you making?