00:00

Spring Boot Actuator – Monitoring, Health Checks and Metrics

Spring Boot Actuator is a module of Spring Boot that helps you monitor, manage and observe your application while it runs, especially in production. It adds ready-made endpoints that tell you about the application’s health, metrics, configuration, environment and runtime behavior, without you writing any of that code yourself.

In this guide you will add Actuator to an online shop’s order service, step by step: checking health, writing your own health checks, setting up Kubernetes probes, recording business metrics, exporting them to Prometheus, changing log levels without a restart, building a custom endpoint, and locking everything down safely.

1. What Is Spring Boot Actuator?

Once an application is deployed, you need answers to simple questions: Is it running? Can it reach the database? How many requests per second is it handling, and how fast? Which configuration value is it actually using? Actuator answers these questions through a set of endpoints, small read-only (and sometimes writable) URLs such as /actuator/health and /actuator/metrics.

Think of Actuator as the dashboard of a car. The engine works without it, but the fuel gauge, speedometer and warning lights tell you what is happening inside and warn you before something goes wrong.

QuestionEndpoint that answers it
Is the app up, and can it reach its database?/actuator/health
Which version is running?/actuator/info
How fast are the HTTP requests? How much memory is used?/actuator/metrics
Can I turn on debug logging without a restart?/actuator/loggers
Which beans and auto-configurations were created?/actuator/beans, /actuator/conditions
Which URLs does the app handle?/actuator/mappings

Actuator is built on Micrometer, a vendor-neutral metrics library, so the same numbers can be sent to Prometheus, Datadog, New Relic, CloudWatch and many other tools.

2. Adding Actuator to Your Project

Add the Actuator starter to your pom.xml. No version is needed when you use the Spring Boot parent or BOM.

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

For Gradle, add this line to build.gradle:

build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'

Start the application and open http://localhost:8080/actuator. You get a list of the endpoints that are currently available:

GET /actuator
{
  "_links": {
    "self":          { "href": "http://localhost:8080/actuator", "templated": false },
    "health":        { "href": "http://localhost:8080/actuator/health", "templated": false },
    "health-path":   { "href": "http://localhost:8080/actuator/health/{*path}", "templated": true }
  }
}

Only health appears. That is on purpose: by default, Spring Boot exposes only the health endpoint over HTTP, because the others can reveal internal details. You choose what else to expose in section 4.

3. Built-in Endpoints at a Glance

Here are the endpoints you will use most often. Each one is reached at /actuator/{id}.

IDWhat it shows or doesSensitive?
healthWhether the app and its dependencies (database, disk, and so on) are workingLow (details can be hidden)
infoApplication name, version, build and Git detailsLow
metricsMemory, CPU, HTTP request timings, custom business numbersMedium
prometheusAll metrics in Prometheus text format (needs an extra dependency)Medium
loggersShows and changes log levels at runtimeMedium
envProperties and their sources (values are masked by default)High
configpropsAll @ConfigurationProperties beans and their values (masked by default)High
beansEvery bean in the application contextMedium
conditionsWhy each auto-configuration did or did not applyMedium
mappingsAll @RequestMapping paths and their handler methodsMedium
scheduledtasksAll @Scheduled jobs and their timingLow
cachesThe caches the application uses; it can also clear themMedium
threaddumpA snapshot of every thread, useful for finding hangsHigh
heapdumpDownloads a full memory dump (can contain passwords and personal data)Very high
httpexchangesRecent HTTP requests and responses (needs an HttpExchangeRepository bean)High
shutdownStops the application (turned off by default)Very high

Some endpoints appear only when the related technology is present, for example flyway, liquibase, quartz and sessions.

4. Exposing and Controlling Endpoints

4.1 Choosing which endpoints are reachable

An endpoint must be exposed before you can call it over HTTP. List the IDs you want in application.properties:

application.properties
# Expose these endpoints over HTTP
management.endpoints.web.exposure.include=health,info,metrics,loggers

# Never expose these, even if "include" matches them
management.endpoints.web.exposure.exclude=env,beans
  • exclude always wins over include.
  • include=* exposes everything. It is handy on your own machine but risky in production.
  • In YAML, the star must be quoted: include: "*", because a bare * has a special meaning in YAML.

4.2 Controlling access to endpoints

Exposure decides whether an endpoint is reachable. Access decides whether it exists at all and which operations it allows. In Spring Boot 3.4 and later you can switch every endpoint off by default and turn on only the ones you need:

application.properties
# Turn every endpoint off unless it is listed below
management.endpoints.access.default=none

# Allow reading only
management.endpoint.health.access=read-only
management.endpoint.info.access=read-only
management.endpoint.metrics.access=read-only

# Allow reading and changing log levels
management.endpoint.loggers.access=unrestricted
Access valueMeaning
noneThe endpoint is off and cannot be called
read-onlyOnly read operations (HTTP GET) are allowed
unrestrictedRead, write (POST) and delete (DELETE) operations are allowed

Older versions of Spring Boot use management.endpoints.enabled-by-default=false and management.endpoint.<id>.enabled=true for the same purpose.

4.3 Renaming an endpoint’s path

You can change the URL of a single endpoint without changing its ID:

application.properties
# /actuator/health becomes /actuator/healthcheck
management.endpoints.web.path-mapping.health=healthcheck

5. The Health Endpoint

The health endpoint is the one you will use most. Load balancers, Kubernetes and monitoring tools call it to decide whether your application can receive traffic.

GET /actuator/health
{
  "status": "UP"
}

By default you only see the overall status. Spring Boot automatically adds health checks, called health indicators, for things it finds in your project: a DataSource, Redis, MongoDB, RabbitMQ, Kafka, disk space and more. To see each one, turn on the details:

application.properties
# never (default) | when-authorized | always
management.endpoint.health.show-details=when-authorized
management.endpoint.health.roles=ADMIN

A user with the ADMIN role now sees a full report:

GET /actuator/health (with details)
{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP",
      "details": { "database": "PostgreSQL", "validationQuery": "isValid()" }
    },
    "diskSpace": {
      "status": "UP",
      "details": { "total": 499963174912, "free": 91493904384, "threshold": 10485760, "exists": true }
    },
    "ping": { "status": "UP" }
  }
}

5.1 Health statuses

StatusMeaningDefault HTTP code
UPEverything works200
DOWNA component is broken503
OUT_OF_SERVICETaken out of use on purpose, for example during maintenance503
UNKNOWNThe state could not be determined200

The overall status is the worst status among all components. If the database is DOWN, the whole application reports DOWN.

5.2 Turning off a health check you do not need

Sometimes a library is on the classpath but not really used, and its health check fails. You can switch off a single built-in indicator:

application.properties
# Do not check the mail server
management.health.mail.enabled=false

6. Writing a Custom Health Check

Our order service calls an external payment gateway. If that gateway is unreachable, we want the health report to say so. Implement HealthIndicator and register it as a bean:

PaymentGatewayHealthIndicator.java
package com.example.shop.health;

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

import com.example.shop.payment.PaymentGatewayClient;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final PaymentGatewayClient client;

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

    @Override
    public Health health() {
        long start = System.currentTimeMillis();
        try {
            boolean reachable = client.ping();
            long tookMs = System.currentTimeMillis() - start;

            if (reachable) {
                return Health.up()
                        .withDetail("responseTimeMs", tookMs)
                        .build();
            }
            return Health.down()
                    .withDetail("reason", "Gateway did not answer the ping")
                    .build();

        } catch (Exception ex) {
            // Health.down(ex) adds the error message to the details
            return Health.down(ex).build();
        }
    }
}

The component name comes from the bean name without the HealthIndicator suffix, so it appears as paymentGateway:

GET /actuator/health (part)
"paymentGateway": {
  "status": "UP",
  "details": { "responseTimeMs": 42 }
}
Note: The imports in this guide are for Spring Boot 3.x. Spring Boot 4 moved some Actuator classes, such as Health and HealthIndicator, into new packages. The class names and code stay the same, so let your IDE fix the imports.

Tips for custom health checks:

  • Keep them fast. Use short timeouts on the client, for example one or two seconds. A slow check makes every health call slow.
  • Never throw from health(). Catch the error and return Health.down(ex).
  • Do not put secrets in details. Details may be shown to more people than you expect.

7. Health Groups and Kubernetes Probes

7.1 Health groups

A health group is a named subset of health checks with its own URL. This lets different tools ask different questions:

application.properties
# /actuator/health/core checks only the database and disk
management.endpoint.health.group.core.include=db,diskSpace
management.endpoint.health.group.core.show-details=always

7.2 Liveness and readiness probes

Kubernetes asks two different questions about every container:

ProbeQuestionWhat happens on failureURL
LivenessIs the app stuck or broken beyond repair?Kubernetes restarts the container/actuator/health/liveness
ReadinessCan the app handle requests right now?Kubernetes stops sending traffic to it until it is ready again/actuator/health/readiness

Spring Boot turns these probe groups on automatically when it detects Kubernetes. To use them anywhere else, such as on your own machine, enable them:

application.properties
management.endpoint.health.probes.enabled=true

# Optional: add the database check to readiness
management.endpoint.health.group.readiness.include=readinessState,db

Then point Kubernetes at them in your deployment file:

deployment.yaml
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 20
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  periodSeconds: 5
Important: Do not add external systems such as the database to the liveness probe. If the database goes down, restarting your application will not fix it, and Kubernetes may restart every instance in a loop. External checks belong in readiness, if anywhere. Even then, remember that a shared database failure will make all instances “not ready” at the same time.

7.3 Changing readiness from your code

You can tell Kubernetes that the application is temporarily busy, for example while a large cache is being reloaded:

CacheReloader.java
package com.example.shop.catalog;

import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

@Component
public class CacheReloader {

    private final ApplicationEventPublisher publisher;
    private final ProductCache productCache;

    public CacheReloader(ApplicationEventPublisher publisher, ProductCache productCache) {
        this.publisher = publisher;
        this.productCache = productCache;
    }

    public void reload() {
        // Stop receiving traffic while the cache is empty
        AvailabilityChangeEvent.publish(publisher, this, ReadinessState.REFUSING_TRAFFIC);
        try {
            productCache.reloadAll();
        } finally {
            // Accept traffic again, even if the reload failed
            AvailabilityChangeEvent.publish(publisher, this, ReadinessState.ACCEPTING_TRAFFIC);
        }
    }
}

8. The Info Endpoint

The info endpoint describes what is running. It is empty until you give it something to show.

8.1 Static information from properties

application.properties
management.info.env.enabled=true
info.app.name=Order Service
info.app.description=Takes and tracks customer orders
info.app.team=checkout-team@example.com

# Add Java and operating system details
management.info.java.enabled=true
management.info.os.enabled=true

8.2 Build and Git details

Knowing exactly which build is deployed saves a lot of guessing. Ask the Spring Boot Maven plugin to generate build information:

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

<!-- Adds the Git branch and commit to /actuator/info -->
<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>
</plugin>
GET /actuator/info
{
  "app": {
    "name": "Order Service",
    "description": "Takes and tracks customer orders",
    "team": "checkout-team@example.com"
  },
  "build": {
    "artifact": "order-service",
    "version": "2.4.1",
    "time": "2026-10-01T09:15:42Z"
  },
  "git": {
    "branch": "main",
    "commit": { "id": "4f9c2ab", "time": "2026-09-30T17:02:11Z" }
  }
}

8.3 Adding your own information

FeatureFlagsInfoContributor.java
package com.example.shop.info;

import java.util.Map;

import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.stereotype.Component;

@Component
public class FeatureFlagsInfoContributor implements InfoContributor {

    @Override
    public void contribute(Info.Builder builder) {
        builder.withDetail("features", Map.of(
                "newCheckout", true,
                "giftCards", false));
    }
}

9. Metrics with Micrometer

9.1 Built-in metrics

As soon as Actuator is added, Spring Boot records many metrics for you: JVM memory and garbage collection, CPU, threads, HTTP requests, database connection pools, caches and more. Expose the metrics endpoint and list the metric names:

Terminal
# List every metric name
curl http://localhost:8080/actuator/metrics

# Look at one metric
curl http://localhost:8080/actuator/metrics/jvm.memory.used

# Narrow it down with tags: successful calls to /orders only
curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/orders&tag=status:200"
GET /actuator/metrics/http.server.requests?tag=uri:/orders
{
  "name": "http.server.requests",
  "baseUnit": "seconds",
  "measurements": [
    { "statistic": "COUNT",      "value": 1520 },
    { "statistic": "TOTAL_TIME", "value": 63.84 },
    { "statistic": "MAX",        "value": 0.412 }
  ],
  "availableTags": [
    { "tag": "method", "values": ["GET", "POST"] },
    { "tag": "status", "values": ["200", "201", "400"] }
  ]
}

Here, 1,520 requests took 63.84 seconds in total, an average of about 42 ms each, and the slowest recent one took 412 ms.

9.2 The three main meter types

MeterWhat it measuresExample
CounterA number that only goes upOrders placed, failed payments
GaugeA current value that goes up and downItems in a queue, active shopping carts
TimerHow long something takes, and how often it happensCheckout duration

9.3 Recording your own business metrics

Technical metrics are useful, but business metrics often tell you first that something is wrong. Inject MeterRegistry and create your meters once, in the constructor:

OrderService.java
package com.example.shop.order;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final OrderRepository repository;
    private final MeterRegistry registry;
    private final Counter ordersPlaced;
    private final Timer checkoutTimer;
    private final Map<String, Cart> activeCarts = new ConcurrentHashMap<>();

    public OrderService(OrderRepository repository, MeterRegistry registry) {
        this.repository = repository;
        this.registry = registry;

        this.ordersPlaced = Counter.builder("shop.orders.placed")
                .description("Number of orders placed")
                .register(registry);

        this.checkoutTimer = Timer.builder("shop.checkout.duration")
                .description("Time taken to complete a checkout")
                .register(registry);

        // A gauge reads the current size of the map whenever it is scraped
        Gauge.builder("shop.carts.active", activeCarts, Map::size)
                .description("Shopping carts currently open")
                .register(registry);
    }

    public Order placeOrder(OrderRequest request) {
        return checkoutTimer.record(() -> {
            Order order = repository.save(Order.from(request));
            ordersPlaced.increment();
            activeCarts.remove(request.cartId());
            return order;
        });
    }

    public void recordFailedPayment(String paymentMethod) {
        // A tag splits one metric into a few groups, such as card or wallet
        registry.counter("shop.payments.failed", "method", paymentMethod).increment();
    }
}

Your metrics now appear at /actuator/metrics/shop.orders.placed, /actuator/metrics/shop.checkout.duration and so on.

Keep tag values few. A tag such as method=card has a handful of possible values, which is fine. Never use a user ID, order ID or email address as a tag value: every new value creates a new time series, which can use up memory and overload your monitoring system.

9.4 Common tags for every metric

When many services send metrics to one place, add tags that say where each number came from:

application.properties
management.metrics.tags.application=order-service
management.metrics.tags.environment=production

10. Exporting Metrics to Prometheus and Grafana

Actuator keeps only the latest values in memory. To store history and draw graphs, a monitoring system such as Prometheus collects (“scrapes”) the metrics every few seconds, and Grafana turns them into dashboards.

Step 1: add the Prometheus registry:

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

Step 2: expose the endpoint, and optionally turn on histograms so Prometheus can calculate response-time percentiles:

application.properties
management.endpoints.web.exposure.include=health,info,prometheus
management.metrics.distribution.percentiles-histogram.http.server.requests=true

/actuator/prometheus now returns plain text in the format Prometheus understands:

GET /actuator/prometheus (part)
# HELP shop_orders_placed_total Number of orders placed
# TYPE shop_orders_placed_total counter
shop_orders_placed_total{application="order-service",environment="production"} 128.0

Micrometer converts the dots in metric names to underscores and adds _total to counters, following Prometheus naming rules.

Step 3: tell Prometheus where to scrape:

prometheus.yml
scrape_configs:
  - job_name: "order-service"
    metrics_path: "/actuator/prometheus"
    scrape_interval: 15s
    static_configs:
      - targets: ["order-service:8080"]

In Grafana, add Prometheus as a data source and build panels such as orders per minute: rate(shop_orders_placed_total[5m]) * 60.

11. Changing Log Levels at Runtime

A bug appears only in production, and the logs are at INFO. Instead of redeploying with more logging, change the level while the application runs.

Terminal
# Check the current level for a package
curl http://localhost:8080/actuator/loggers/com.example.shop.order

# Switch it to DEBUG
curl -X POST http://localhost:8080/actuator/loggers/com.example.shop.order \
     -H "Content-Type: application/json" \
     -d '{"configuredLevel": "DEBUG"}'

# Go back to the configured default
curl -X POST http://localhost:8080/actuator/loggers/com.example.shop.order \
     -H "Content-Type: application/json" \
     -d '{"configuredLevel": null}'
GET /actuator/loggers/com.example.shop.order
{
  "configuredLevel": "DEBUG",
  "effectiveLevel": "DEBUG"
}
  • The change affects only this running instance. Other instances, and this one after a restart, keep the level from your configuration.
  • Remember to switch the level back. Debug logging in production produces a lot of output and can slow the application down.
  • The loggers endpoint needs unrestricted access (the default unless you changed it) to allow the POST.

12. Creating a Custom Endpoint

When the built-in endpoints are not enough, write your own. This endpoint lets an operator read and change feature flags at /actuator/features:

FeaturesEndpoint.java
package com.example.shop.actuator;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.stereotype.Component;

@Component
@Endpoint(id = "features")
public class FeaturesEndpoint {

    private final Map<String, Boolean> features = new ConcurrentHashMap<>(
            Map.of("newCheckout", true, "giftCards", false));

    // GET /actuator/features
    @ReadOperation
    public Map<String, Boolean> all() {
        return features;
    }

    // GET /actuator/features/{name}  (returning null gives a 404)
    @ReadOperation
    public Boolean one(@Selector String name) {
        return features.get(name);
    }

    // POST /actuator/features/{name}  with body {"enabled": true}
    @WriteOperation
    public void update(@Selector String name, boolean enabled) {
        features.put(name, enabled);
    }

    // DELETE /actuator/features/{name}
    @DeleteOperation
    public void remove(@Selector String name) {
        features.remove(name);
    }
}

Like any other endpoint, it must be exposed: management.endpoints.web.exposure.include=health,info,features.

AnnotationHTTP methodPurpose
@Endpoint(id = "...")–Marks the class as an endpoint available over HTTP and JMX
@ReadOperationGETReturns data
@WriteOperationPOSTChanges data; parameters are read from the JSON body
@DeleteOperationDELETERemoves data
@Selector–Takes a value from the URL path, such as {name}

Use @WebEndpoint instead of @Endpoint if the endpoint should be available over HTTP only.

13. Securing Actuator Endpoints

Actuator can reveal private information and even change how your application behaves, so treat it like an admin panel. Never leave it open on a public server. Use several layers of protection together.

13.1 Using Spring Security

Add the security starter:

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

Then create a security configuration. EndpointRequest matches Actuator URLs even if you change the base path or port later:

SecurityConfig.java
package com.example.shop.config;

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.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Health and info are public
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // Every other Actuator endpoint needs the ADMIN role
                .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ADMIN")
                // The rest of the application needs a logged-in user
                .anyRequest().authenticated()
            )
            // Allow POST to endpoints like /actuator/loggers from tools such as curl
            .csrf(csrf -> csrf.ignoringRequestMatchers(EndpointRequest.toAnyEndpoint()))
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}

This makes health and info public and limits everything else to users with the ADMIN role. Without the CSRF exception, POST requests from scripts would be rejected with 403 Forbidden.

13.2 Running Actuator on a different port

A very common approach is to put Actuator on a separate internal port that is not reachable from the internet:

application.properties
management.server.port=9090

# Optional: listen only on the local network interface
management.server.address=127.0.0.1

Your application keeps serving customers on port 8080, while Actuator lives on 9090. Block port 9090 in your firewall or cloud security group, and let only your monitoring system reach it. Setting management.server.port=-1 turns off Actuator’s HTTP endpoints completely.

13.3 Changing the base path

application.properties
management.endpoints.web.base-path=/manage

Endpoints now live at /manage/health, /manage/info and so on. This is not real security, only a small extra on top of the steps above.

13.4 Hiding sensitive values

The env and configprops endpoints show every value as ****** by default. If you need to see real values, show them only to authorized users:

application.properties
# never (default) | when-authorized | always
management.endpoint.env.show-values=when-authorized
management.endpoint.configprops.show-values=when-authorized

14. Handy Configuration Cheat Sheet

A ready-to-use starting point for production. Copy it and remove what you do not need.

application.properties
# Which endpoints are visible over HTTP
management.endpoints.web.exposure.include=health,info,metrics,prometheus,loggers

# Health details and Kubernetes probes
management.endpoint.health.show-details=when-authorized
management.endpoint.health.probes.enabled=true

# Info
management.info.env.enabled=true
management.info.git.mode=full

# Separate port and custom path
management.server.port=9090
management.endpoints.web.base-path=/manage

# Add common tags to all metrics
management.metrics.tags.application=my-shop-service
management.metrics.tags.environment=production

# Show configuration values only to authorized users
management.endpoint.env.show-values=when-authorized
management.endpoint.configprops.show-values=when-authorized

The same settings in YAML:

application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers
      base-path: /manage
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  server:
    port: 9090
  metrics:
    tags:
      application: my-shop-service
      environment: production

15. Testing Actuator Endpoints

15.1 Testing a health indicator as a plain unit test

A health indicator is an ordinary class, so you can test it without starting Spring:

PaymentGatewayHealthIndicatorTest.java
package com.example.shop.health;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;

import com.example.shop.payment.PaymentGatewayClient;

class PaymentGatewayHealthIndicatorTest {

    private final PaymentGatewayClient client = mock(PaymentGatewayClient.class);
    private final PaymentGatewayHealthIndicator indicator = new PaymentGatewayHealthIndicator(client);

    @Test
    void reportsUpWhenGatewayAnswers() {
        when(client.ping()).thenReturn(true);

        Health health = indicator.health();

        assertThat(health.getStatus()).isEqualTo(Status.UP);
        assertThat(health.getDetails()).containsKey("responseTimeMs");
    }

    @Test
    void reportsDownWhenGatewayThrows() {
        when(client.ping()).thenThrow(new IllegalStateException("Connection timed out"));

        Health health = indicator.health();

        assertThat(health.getStatus()).isEqualTo(Status.DOWN);
    }
}

15.2 Testing endpoint security

Check that the public endpoints stay public and the private ones stay private:

ActuatorSecurityTest.java
package com.example.shop;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class ActuatorSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void healthIsPublic() throws Exception {
        mockMvc.perform(get("/actuator/health"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.status").value("UP"));
    }

    @Test
    void metricsRejectAnonymousUsers() throws Exception {
        mockMvc.perform(get("/actuator/metrics"))
               .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanReadMetrics() throws Exception {
        mockMvc.perform(get("/actuator/metrics"))
               .andExpect(status().isOk());
    }
}

MockMvc calls the application’s main port, so run these tests without a separate management.server.port, for example by leaving it out of your test configuration. The health test also expects every health check to be UP, so make sure the test database is available. See the Spring Boot Testing guide for more about @SpringBootTest and MockMvc.

16. Best Practices

  1. Only expose what you need. Avoid include=* in production.
  2. Secure everything except health and info. Even those can be locked down if needed.
  3. Use a separate management port and keep it off the public internet.
  4. Use when-authorized for health details instead of always.
  5. Add a common application tag to your metrics so dashboards can tell your services apart.
  6. Keep custom health checks fast. A slow check makes your health endpoint slow, and Kubernetes may think your app is dead.
  7. Keep external systems out of liveness checks. If your database is down, restarting your app will not fix it. Use readiness for that, if at all.
  8. Protect heap dumps and thread dumps. They can contain passwords, tokens and personal data.
  9. Record business metrics, such as orders and failed payments, not only CPU and memory.
  10. Keep tag values few. Never use IDs or emails as tag values.
  11. Publish build and Git information so you always know which version is running.
  12. Set up alerts, not just dashboards. Nobody watches Grafana all day.

17. Common Mistakes and Troubleshooting

ProblemLikely causeFix
404 on /actuator/metricsThe endpoint is not exposedAdd it to management.endpoints.web.exposure.include
404 on /actuator/health/livenessProbes are only on by default in KubernetesSet management.endpoint.health.probes.enabled=true
403 when sending a POST to /actuator/loggersSpring Security’s CSRF protection blocks itIgnore CSRF for Actuator requests, as in section 13.1
Health is DOWN but the app worksAn unused library’s health check failsShow details to find it, then fix it or set management.health.<name>.enabled=false
Pods restart again and againLiveness probe checks the database, or the start-up delay is too shortRemove external checks from liveness; raise initialDelaySeconds or use a startup probe
Health endpoint is very slowA custom health check has no timeoutAdd short timeouts to the client inside the check
env shows ****** everywhereValues are masked by defaultSet show-values=when-authorized and log in as an authorized user
Monitoring system runs out of memoryA tag uses IDs or emails, creating millions of time seriesUse tags with a small, fixed set of values
/actuator is open to the internetExposed endpoints with no securityAdd Spring Security, a separate port and a firewall rule

18. Summary

Spring Boot Actuator gives your application a built-in control panel. Add one starter, and you get a health endpoint that load balancers and Kubernetes understand, detailed metrics through Micrometer, runtime log-level changes, and build information that tells you exactly what is deployed. Extend it with your own health checks, business metrics and endpoints, send the metrics to Prometheus and Grafana, and always protect it with careful exposure, Spring Security and a separate management port. Set up this way, Actuator helps you spot problems early and fix them quickly.