Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 034 / 100

Spring Boot Actuator: health and metrics for production

Production readiness in one dependency.

Expona Admin7 min read

Spring Boot Actuator: health and metrics for production

When your Spring Boot application goes live, ops teams need visibility into what's happening under the hood—and they need it fast. Spring Boot Actuator provides built-in endpoints that expose health status, performance metrics, and runtime information without writing custom monitoring infrastructure. We'll walk through how to configure health probes for Kubernetes, export metrics to Prometheus, implement custom health checks for critical services, and lock down the management endpoints to prevent information leakage.

Why actuator matters in production

Before diving into configuration, let's establish the stakes. Production incidents often stem from silent failures—a database connection pool exhausted, a third-party API timing out, a cache server becoming unreachable. Your application might still start successfully and accept requests, but it's operating in a degraded state. Load balancers need to know this. Kubernetes orchestrators need to know this. Monitoring systems need quantifiable data.

Spring Boot Actuator solves this by exposing standardized endpoints that both machines and humans can consume. It's production readiness in one dependency, and it's been battle-tested across thousands of deployments.

Health endpoints: Liveness and readiness probes for Kubernetes and load balancers

Modern containerized deployments rely on health probes. Kubernetes distinguishes between two critical checks: liveness (is the process still running?) and readiness (is it safe to send traffic?). A pod can be alive but not ready if it's warming up or a critical downstream service is temporarily unavailable.

Spring Boot Actuator exposes separate endpoints for each probe. Here's how to enable them:

java
management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  endpoint:
    health:
      probes:
        enabled: true
      show-details: when-authorized
      show-components: when-authorized
  health:
    livenessState:
      enabled: true
    readinessState:
      enabled: true

With this configuration, you get three health endpoints:

  • /actuator/health — aggregated health status (useful for dashboards)
  • /actuator/health/liveness — returns UP if the JVM is alive, DOWN if it has detected a fatal issue
  • /actuator/health/readiness — returns UP only if all critical components are operational

In your Kubernetes deployment manifest, wire these to probes:

java
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  template:
    spec:
      containers:
      - name: app
        image: my-app:latest
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5

Now Kubernetes automatically removes pods that fail the readiness probe from the load balancer, and restarts pods that fail liveness checks. Load balancers (AWS ELB, etc.) use the same pattern.

The distinction matters: a deployment with all pods "alive" but "not ready" scales to zero traffic gracefully instead of crashing. This buys your team time to investigate and recover.

Metrics with Micrometer: JVM, HTTP and datasource metrics exported to Prometheus

Raw health checks tell you if something is broken. Metrics tell you why and help predict failures before they happen. Spring Boot integrates Micrometer, a vendor-neutral metrics facade that collects JVM metrics, HTTP request metrics, database pool stats, and more.

Add the dependency:

java
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Enable the Prometheus endpoint:

java
management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  metrics:
    export:
      prometheus:
        enabled: true

Visit /actuator/prometheus to see all metrics in Prometheus scrape format. You'll get dozens of metrics automatically:

  • JVM memory: jvm_memory_used_bytes, jvm_memory_max_bytes
  • HTTP requests: http_server_requests_seconds (with buckets for latency percentiles)
  • Database connections: jdbc_connections_active, jdbc_connections_idle
  • Threads: jvm_threads_live, jvm_threads_peak
  • GC pauses: jvm_gc_pause_seconds

To ingest these into Prometheus, add a scrape job:

java
scrape_configs:
  - job_name: 'my-app'
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: '/actuator/prometheus'
    scrape_interval: 15s

From here, you build Grafana dashboards that alert on memory pressure, connection pool saturation, elevated p99 latency, and GC pause spikes. These dashboards become your early warning system.

Real example: a team noticed HTTP request latency drifting upward over several hours, captured in `http_server_requests_seconds`. They correlated it with rising `jdbc_connections_active` and discovered an N+1 query bug that was exhausting the pool. The metric gave them 6 hours to fix the issue before customers felt the impact.

Custom health checks: Implement HealthIndicator for critical dependencies

The default health indicators cover JVM, database, and message brokers. But what about your payment gateway, SMS provider, or internal API? Kubernetes won't know the pod is broken if those external dependencies fail.

Implement HealthIndicator for anything mission-critical:

java
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final PaymentGatewayClient paymentClient;

    public PaymentGatewayHealthIndicator(PaymentGatewayClient paymentClient) {
        this.paymentClient = paymentClient;
    }

    @Override
    public Health health() {
        try {
            PaymentGatewayStatus status = paymentClient.getStatus();
            
            if (status.isAvailable()) {
                return Health.up()
                    .withDetail("provider", "Stripe")
                    .withDetail("latency_ms", status.getLatencyMs())
                    .withDetail("rate_limit_remaining", status.getRateLimitRemaining())
                    .build();
            } else {
                return Health.degraded()
                    .withDetail("reason", "Gateway returning errors")
                    .withDetail("error_rate", status.getErrorRate())
                    .build();
            }
        } catch (Exception e) {
            return Health.down()
                .withDetail("error", e.getMessage())
                .withException(e)
                .build();
        }
    }
}

This health indicator now appears in the readiness probe response. If Stripe goes down, your pod becomes "not ready" immediately, Kubernetes removes it from traffic, and your ops team gets an alert.

Notice the Health.degraded() state—useful for situations where the service is partially broken (high error rate, slow responses) but not completely unavailable. You can configure Kubernetes to tolerate degraded state or not, depending on your SLAs.

Add timeout protection so a hung external service doesn't hang your readiness probe:

java
@Override
public Health health() {
    try {
        PaymentGatewayStatus status = CompletableFuture
            .supplyAsync(() -> paymentClient.getStatus())
            .get(2, TimeUnit.SECONDS);  // timeout after 2 seconds
        // ... rest of logic
    } catch (TimeoutException e) {
        return Health.down()
            .withDetail("error", "Health check timed out")
            .build();
    } catch (Exception e) {
        return Health.down()
            .withDetail("error", e.getMessage())
            .build();
    }
}

Lock it down: Expose only what you need and secure the rest

The actuator endpoints contain valuable information: database pool state, configured properties, environment variables, running threads. In the wrong hands, this becomes a reconnaissance vector for attackers.

First, restrict what endpoints are exposed:

java
management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
        exclude: env, configprops, beans, shutdown
  endpoint:
    health:
      show-details: when-authorized  # only show details to authenticated users
    metrics:
      enabled: true

Never expose env, configprops, or shutdown` to the public internet. Health, metrics, and prometheus are safe for public consumption (they don't leak sensitive data).

Second, enforce authentication. Place actuator endpoints behind your security layer:

java
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize ->
                authorize
                    .requestMatchers(EndpointRequest.to("health", "metrics", "prometheus"))
                        .permitAll()
                    .requestMatchers(EndpointRequest.toAnyEndpoint())
                        .hasRole("ADMIN")
                    .anyRequest()
                        .permitAll()
            );
        return http.build();
    }
}

With this config, health/metrics/prometheus are public (your Kubernetes probes and Prometheus scraper work without authentication), but anything else requires ADMIN role.

For production, run actuator on a separate management port invisible to the public internet:

java
management:
  server:
    port: 9090
  endpoints:
    web:
      base-path: /actuator

Now your Kubernetes cluster, monitoring infra, and ops team hit port 9090 (internal only), while customer traffic hits port 8080. Clean separation.

Common mistakes

  • Exposing everything: Setting `include: "*"` is a security anti-pattern. Restrict to health, metrics, and prometheus. Everything else should be authenticated and internal.
  • Ignoring timeouts on external health checks: A health check that hangs for 30 seconds defeats its purpose. Always wrap external calls in a timeout (2-5 seconds).
  • Not correlating health state with traffic decisions: Readiness probes are only useful if your load balancer actually respects them. Verify Kubernetes config is wired correctly.
  • Custom health checks that do too much work: A health check runs every 5-10 seconds. Expensive operations (full DB scans, large API calls) will create a denial-of-service on your own infrastructure. Keep checks light.
  • Forgetting to export metrics: Adding the micrometer dependency alone doesn't help if you don't scrape the endpoint. Configure Prometheus and test the scrape job.

Key takeaways

  • Health probes (liveness and readiness) are mandatory in Kubernetes deployments. Wire them to /actuator/health/liveness and /actuator/health/readiness.
  • Micrometer metrics exported to Prometheus enable early detection of resource exhaustion, latency drift, and error spikes before they become production incidents.
  • Implement HealthIndicator for any critical external dependency (payment gateways, auth services, etc.). Add timeout protection to prevent cascading failures.
  • Restrict actuator endpoint exposure: public for health/metrics/prometheus, authenticated for everything else. Run the management port on an internal network if possible.
  • Test your health checks in staging. Verify that Kubernetes actually removes traffic when readiness fails and that your monitoring system scrapes metrics correctly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles