Spring Boot Actuator in Depth — Custom Endpoints, Health Groups, and Production Configuration

by Eric Hanson, Backend Developer at Clean Systems Consulting

The exposure problem — what's on by default

Spring Boot Actuator enables the health and info endpoints over HTTP by default. Every other endpoint is disabled over HTTP but available over JMX. For production services, this means:

  • /actuator/health — exposed, shows UP/DOWN aggregate
  • /actuator/metrics — not exposed (requires explicit inclusion)
  • /actuator/env — not exposed (exposes environment variables including secrets if enabled)
  • /actuator/loggers — not exposed (allows runtime log level changes if enabled)

This is the correct default. The problem is that teams add management.endpoints.web.exposure.include=* to enable metrics and then forget they've also exposed env, heapdump, and shutdown.

The production configuration that exposes what's useful and nothing dangerous:

management:
  endpoints:
    web:
      exposure:
        include:
          - health
          - info
          - metrics
          - prometheus
          - loggers      # runtime log level adjustment — secure this
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
    loggers:
      enabled: true
  server:
    port: 8081           # management on separate port — not exposed to public load balancer

Running management endpoints on a separate port (management.server.port) is the cleanest security boundary. The main application port is publicly accessible; the management port is accessible only within the cluster or VPN. No Spring Security configuration needed — network isolation handles access control.

If a separate port isn't feasible, secure actuator endpoints with Spring Security:

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)  // higher priority than the main security filter chain
    public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                .requestMatchers(EndpointRequest.to("prometheus")).hasIpAddress("10.0.0.0/8")
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

EndpointRequest.toAnyEndpoint() matches all actuator paths regardless of the base path configuration. EndpointRequest.to("health") matches the specific endpoint.

Health indicators — what to check and how to group them

The default health indicators check: datasource connectivity, disk space, mail server connectivity (if configured), Redis connectivity (if configured), and a few others. These are registered automatically when the relevant Spring Boot starters are present.

Custom health indicator for an external dependency your application requires:

@Component
public class ExternalApiHealthIndicator implements HealthIndicator {

    private final ExternalApiClient client;
    private final Duration timeout;

    public ExternalApiHealthIndicator(ExternalApiClient client) {
        this.client = client;
        this.timeout = Duration.ofSeconds(5);
    }

    @Override
    public Health health() {
        long start = System.currentTimeMillis();
        try {
            ApiStatus status = client.checkStatus();
            long elapsed = System.currentTimeMillis() - start;

            if (status.isOperational()) {
                return Health.up()
                    .withDetail("api", "external-payments")
                    .withDetail("latency_ms", elapsed)
                    .withDetail("status", status.getCode())
                    .build();
            }

            return Health.down()
                .withDetail("api", "external-payments")
                .withDetail("status", status.getCode())
                .withDetail("message", status.getMessage())
                .build();

        } catch (Exception e) {
            return Health.down(e)
                .withDetail("api", "external-payments")
                .withDetail("timeout_ms", timeout.toMillis())
                .build();
        }
    }
}

The health indicator appears in /actuator/health response under its bean name (stripped of HealthIndicator suffix): externalApi.

Health groups separate indicators into logical categories. The most important grouping is liveness vs readiness for Kubernetes:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        liveness:
          include: ping, diskSpace
        readiness:
          include: db, redis, externalApi
      show-components: always
      show-details: always

This creates:

  • /actuator/health/liveness — checks ping and diskSpace
  • /actuator/health/readiness — checks db, redis, and externalApi

Liveness (/actuator/health/liveness): is the application alive and worth keeping running? Should only fail for unrecoverable states — infinite loop, deadlock, corrupted memory. External dependency failures should never fail liveness — a database being down doesn't mean the application needs to restart.

Readiness (/actuator/health/readiness): is the application ready to receive traffic? Should fail if critical dependencies are unavailable. Kubernetes uses this to stop routing traffic to the pod during startup or when dependencies fail.

Kubernetes configuration:

# deployment.yaml
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8081
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8081
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

Custom Actuator endpoints

When the built-in endpoints don't cover an operational need — exposing application state, triggering a manual operation, querying internal data — create a custom endpoint:

@Component
@Endpoint(id = "cache-stats")
public class CacheStatsEndpoint {

    private final CacheManager cacheManager;

    @ReadOperation   // maps to GET /actuator/cache-stats
    public Map<String, Object> cacheStats() {
        Map<String, Object> stats = new LinkedHashMap<>();
        cacheManager.getCacheNames().forEach(name -> {
            Cache cache = cacheManager.getCache(name);
            if (cache instanceof CaffeineCache caffeineCache) {
                CacheStats cacheStats = caffeineCache.getNativeCache().stats();
                stats.put(name, Map.of(
                    "hitRate", cacheStats.hitRate(),
                    "missRate", cacheStats.missRate(),
                    "evictionCount", cacheStats.evictionCount(),
                    "size", caffeineCache.getNativeCache().estimatedSize()
                ));
            }
        });
        return stats;
    }

    @WriteOperation  // maps to POST /actuator/cache-stats
    public void invalidateCache(@Selector String cacheName) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.clear();
        }
    }

    @DeleteOperation  // maps to DELETE /actuator/cache-stats/{cacheName}
    public void evictCacheEntry(@Selector String cacheName, @Selector String key) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.evict(key);
        }
    }
}

The endpoint ID cache-stats becomes the URL path: /actuator/cache-stats. @ReadOperation maps to GET, @WriteOperation to POST, @DeleteOperation to DELETE. @Selector maps path segments after the endpoint path.

Register the custom endpoint in the exposure configuration:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus, cache-stats

Custom endpoint with JMX and web: @Endpoint registers the endpoint for both JMX and HTTP. Use @WebEndpoint for HTTP only or @JmxEndpoint for JMX only. This separation is useful when an operation is safe for internal tools but not for HTTP exposure.

The info endpoint — application metadata

/actuator/info returns metadata about the application. Useful for deployments: verify which version is running, when it was built, which commit it contains.

Auto-populate from the build:

# application.yml
management:
  info:
    git:
      mode: full  # includes git commit hash, branch, build time
    build:
      enabled: true
    env:
      enabled: true

# Expose custom info properties
info:
  app:
    name: ${spring.application.name}
    description: Order management service
    environment: ${spring.profiles.active:unknown}

For git metadata, add the spring-boot-buildinfo plugin:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>build-info</goal>
            </goals>
        </execution>
    </executions>
</plugin>

This generates META-INF/build-info.properties at build time with the build version, time, and artifact details. Combined with the git-commit-id-maven-plugin, the info endpoint provides:

{
  "git": {
    "branch": "main",
    "commit": {
      "id": "abc123def",
      "time": "2026-04-17T14:30:00Z"
    }
  },
  "build": {
    "version": "1.4.2",
    "time": "2026-04-17T14:45:00Z",
    "artifact": "order-service"
  },
  "app": {
    "name": "order-service",
    "environment": "production"
  }
}

A support team that can call /actuator/info on any instance knows immediately what's deployed and when.

Runtime log level adjustment

/actuator/loggers enables reading and changing log levels at runtime without restart:

# View current log levels
curl http://localhost:8081/actuator/loggers/com.example.payments

# Response
{
  "configuredLevel": null,
  "effectiveLevel": "INFO"
}

# Set to DEBUG for diagnosis
curl -X POST http://localhost:8081/actuator/loggers/com.example.payments \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel": "DEBUG"}'

# Reset after diagnosis
curl -X POST http://localhost:8081/actuator/loggers/com.example.payments \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel": null}'

This is a powerful operational tool — diagnose a production issue with detailed logs, then revert without deployment. Secure it appropriately: changing log levels is a privileged operation. Route it through the internal management port or require a specific role.

The startup endpoint for performance diagnosis

/actuator/startup (Spring Boot 2.5+) returns a timeline of bean initialization events with durations:

management:
  endpoints:
    web:
      exposure:
        include: startup

The response shows which beans took the most time to initialize:

{
  "timeline": {
    "startTime": "2026-04-17T14:30:00Z",
    "events": [
      {
        "startupStep": {"name": "spring.beans.instantiate", "id": 42},
        "startTime": "2026-04-17T14:30:01Z",
        "endTime": "2026-04-17T14:30:03Z",
        "duration": "PT2S",
        "tags": [{"key": "beanName", "value": "entityManagerFactory"}]
      }
    ]
  }
}

Beans with long initialization times are candidates for @Lazy loading or pre-warmed connection pools. The startup endpoint makes slow startup diagnosable rather than mysterious.

Enable it during startup profiling; disable in production (the event data is only meaningful at startup time and adds memory overhead):

spring:
  lifecycle:
    startup-actuator:
      enabled: ${STARTUP_PROFILING_ENABLED:false}

The complete production configuration

management:
  server:
    port: 8081
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include:
          - health
          - info
          - metrics
          - prometheus
          - loggers
          - cache-stats    # custom endpoint
  endpoint:
    health:
      probes:
        enabled: true
      show-details: always   # safe on internal management port
      show-components: always
      group:
        liveness:
          include: ping, diskSpace
        readiness:
          include: db, redis, externalApi
    loggers:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${ENVIRONMENT:unknown}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5, 0.95, 0.99
  info:
    git:
      mode: full
    build:
      enabled: true
    env:
      enabled: true

info:
  app:
    name: ${spring.application.name}
    environment: ${ENVIRONMENT:unknown}

This configuration exposes what's operationally useful, separates it onto a management port inaccessible to the public load balancer, includes Kubernetes-appropriate health groups, and tags all metrics with application and environment for dashboard filtering. It's the baseline every production Spring Boot service should start from.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Transition from Employee to Independent Contractor

Quitting your job sounds exciting… until you realize you have to replace your salary. The shift isn’t just about freedom — it’s about learning how to operate like a business.

Read more

Why “Enjoying WFH” Doesn’t Mean You Deserve Less Pay

Many people assume that working from home is a perk that justifies lower pay. But enjoying the flexibility of WFH doesn’t reduce your skills, effort, or value.

Read more

Using Trello, Notion, or Jira as a Solo Contractor

Project management tools aren’t just for teams. Even as a solo contractor, using Trello, Notion, or Jira can keep your work organized and your brain sane.

Read more

When Your API Integration Explodes in Production

Everything worked fine in testing. Then production hits—and suddenly your API integration turns into a disaster you didn’t see coming.

Read more