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, showsUP/DOWNaggregate/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— checkspinganddiskSpace/actuator/health/readiness— checksdb,redis, andexternalApi
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.