Module 6 ended with a claim and a gap. The claim: CicloUrbana is correct, and ./mvnw verify proves it. The gap: it is not operable. Nobody can ask it whether it is healthy before sending traffic its way, nor whether the database is responding, nor which version is deployed on the Ribalta council's server. When the application hangs at three in the morning, the only tool available is to restart it and hope.

This lesson closes that gap with Spring Boot Actuator, the module that opens the application from the inside and exposes its state over HTTP and JMX. We will look at which endpoints it ships, which ones are worth exposing and which are not, how to secure them with the security chain we already wrote in 05-02, how /actuator/health really works — including the availability probes Kubernetes will need in 08-04 — how to write your own health indicators and endpoints for the Ribalta network, and how to find out, with a single request, exactly which commit is running in production.

Contents

  1. What it means for an application to be operable
  2. Adding Actuator and what shows up straight away
  3. The full endpoint catalogue
  4. Exposure and discovery
  5. A separate management port
  6. Securing Actuator with Spring Security
  7. /actuator/health in depth
  8. Health groups and availability probes
  9. A custom HealthIndicator: StationNetworkHealth
  10. Changing availability with AvailabilityChangeEvent
  11. /actuator/info: which version is deployed
  12. /actuator/loggers: changing the log level on the fly
  13. mappings, configprops, env and secrets
  14. A custom endpoint with @Endpoint
  15. Actuator over JMX
  16. Common Mistakes and Tips
  17. Exercises

  1. What it means for an application to be operable

A correct application does what it promises. An operable application can also be managed by somebody who is not its author, in the middle of the night, without reading the code. These are two independent properties: a system can be impeccable on the inside and a black box on the outside. The difference shows up in concrete questions that the Ribalta council's operations team will ask sooner or later:

Operational question Without Actuator With Actuator
Can I send it traffic yet? Try a real endpoint and see whether it answers GET /actuator/health/readiness
Is it alive or does it need a restart? Check whether the process exists (it does, even when hung) GET /actuator/health/liveness
Is the database responding? Read the log and hunt for exceptions GET /actuator/health with details
Which version is deployed? Ask whoever deployed it GET /actuator/info
I need DEBUG logs for one package, now Change the YAML, rebuild and restart POST /actuator/loggers/com.ciclourbana
Is the nightly job running? Search the log GET /actuator/scheduledtasks

All of those answers already exist inside the process: Spring knows its beans, its configuration, its routes and the state of its connection pool. Actuator does not compute them, it publishes them. That is its entire job, and that is why adding it costs one dependency.

  1. Adding Actuator and what shows up straight away

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

No version, because spring-boot-starter-parent governs it (01-04). On startup, the log shows a new line:

Exposing 1 endpoint(s) beneath base path '/actuator'

One endpoint, not fifteen. Actuator registers all of them internally, but only exposes health over HTTP by default. The reason is security, and it is a lesson learned the hard way: in Spring Boot 1.x almost everything was exposed, and there was a time when searching for /env on the internet returned database credentials belonging to real companies. Since Boot 2 the policy is the opposite: nothing goes out unless you ask for it by name.

The /actuator root returns a HAL index with links to whatever is exposed, handy for discovering what is available without knowing the names by heart. And health answers tersely on purpose:

curl -s http://localhost:8080/actuator/health
# {"status":"UP"}

The aggregate status is tolerably public information; the breakdown is not, which is why it is hidden by default. We open it up in section 7.

  1. The full endpoint catalogue

This is the lesson's reference table. The risk column is the one that decides what gets exposed in Ribalta.

Endpoint What it exposes Risk if leaked Expose?
health Aggregate status and, optionally, each component's Low without details; medium with them (reveals the DB engine, disk paths) Yes, without details in public
info Arbitrary data: version, commit, build date Low, as long as you put nothing sensitive in it Yes
metrics Micrometer metrics (memory, threads, requests); exploited in 09-03 Medium: reveals traffic volume and capacity Authenticated only
env The whole environment: properties, variables, arguments Very high: passwords, JWT secrets, API keys ADMIN only, with sanitised values
beans Every bean in the context with its types and dependencies Medium: a complete map of the internal architecture ADMIN only
configprops The @ConfigurationProperties from 02-05 with their values High: the same problem as env ADMIN only, sanitised
mappings Every registered route and the method it dispatches to Medium: an inventory of the API, internal routes included ADMIN only
loggers Reads and modifies log levels on the fly High: a write endpoint; an attacker can fill the disk ADMIN only
threaddump A dump of every thread with its stack High: class names, and sometimes data held in variables ADMIN only
heapdump Downloads an .hprof with the entire heap Critical: contains tokens, plain-text passwords, personal data Almost never; occasionally and through a tunnel
scheduledtasks Registered @Scheduled tasks and their frequency (07-03) Low ADMIN only
flyway Applied migrations and the schema version (04-08) Low to medium ADMIN only
caches Registered caches; allows clearing them (09-02) Medium: a write endpoint ADMIN only
shutdown Shuts the application down Critical Almost never; disabled by default
httpexchanges The most recent HTTP requests and responses, in memory High: headers carrying other users' tokens ADMIN only, and requires an explicit bean

Three observations about the table. First: shutdown is the only endpoint disabled by default, not merely unexposed — you have to enable it with management.endpoint.shutdown.enabled: true — because its effect is irreversible. Second: heapdump is the most dangerous of the lot and the one that looks least so; a heap dump of CicloUrbana contains the JWTs of every live session, the citizens' personal data and the signing secret. Third: httpexchanges does not work merely by exposing it; it also needs an HttpExchangeRepository bean, precisely so that nobody switches it on without realising what it is storing.

  1. Exposure and discovery

Three properties govern what is visible and where:

management:
  endpoints:
    web:
      base-path: /actuator          # prefix for every management route
      exposure:
        include: health,info,metrics,loggers,mappings,configprops,flyway,scheduledtasks
        exclude: env,heapdump,threaddump
Property Effect
exposure.include Allow-list of endpoints published over HTTP; * publishes them all
exposure.exclude Deny-list that always wins over include, even over *
base-path Changes the prefix of every route (/management, /internal)
management.endpoint.<id>.enabled Turns the endpoint on or off entirely, JMX included

Telling enabled and exposure apart matters. A disabled endpoint does not exist: not over HTTP, not over JMX, not even as a bean. An enabled but unexposed one exists and works, but has no HTTP route. The usual combination in production is enabled + exposed + protected, not disabled, because the health indicators are still needed internally.

About include: "*": the quotes are mandatory in YAML — a bare asterisk is an alias — and, above all, it is an acceptable decision only in development. Writing out the explicit list takes thirty seconds and removes the class of accident where a Spring Boot upgrade exposes an endpoint that did not exist when you wrote the configuration. In 07-02 this list will differ per profile: generous in dev, minimal in prod.

  1. A separate management port

management:
  server:
    port: 8081
    base-path: /actuator

With that, the Ribalta API stays on 8080 and all management moves to http://localhost:8081/actuator/.... It is a recommended practice for a reason that is not cryptographic but topological: it lets the firewall, rather than the application, decide who talks to management.

graph LR
    C[Citizens<br/>Internet] -->|:8080| LB[Load balancer]
    LB -->|:8080 /api/v1| APP[CicloUrbana]
    OPS[Internal operations<br/>network] -->|:8081 /actuator| APP
    K8S[Orchestrator<br/>probes] -->|:8081 /actuator/health| APP
Aspect Single port (8080) Management port (8081)
Network isolation None: management travels over the same public port The port is closed to the outside at the firewall
Risk of a configuration mistake One slip in the security chain exposes Actuator to the internet Even if the chain fails, the port is unreachable
Orchestrator probes They share the port — and the thread pool — with traffic A separate connector: they keep answering under saturation

That last point is more useful than it looks: if the thread pool serving 8080 is exhausted, the health probe travelling over 8080 is left waiting too and the orchestrator concludes the application is dead. Warning: when you change management.server.port, the API's security chain stops applying to Actuator, because they are different ports. Authentication is still required — the next section — and you have to use EndpointRequest, which knows how to resolve the management port, instead of writing the path by hand.

  1. Securing Actuator with Spring Security

In 05-02 we wrote a separate chain with @Order(1) and securityMatcher("/actuator/**"). That version worked, but it has two flaws: the path is written by hand — if somebody changes base-path, security silently stops applying — and it does not account for the management port. Actuator ships its own RequestMatcher that solves both:

package com.ciclourbana.security;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;   // + Spring Security annotations

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)
    SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Health and info: public, queried by the load balancer and the orchestrator
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class))
                    .permitAll()
                // Everything else: ADMIN only
                .anyRequest().hasRole("ADMIN"))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

The decisions, one by one:

  • EndpointRequest.toAnyEndpoint() matches every exposed endpoint, whatever the base-path and whichever port it sits on. That is the difference between a rule that maintains itself and one that silently expires.
  • EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class) identifies endpoints by their class, not by their path. There is an id-based variant, EndpointRequest.to("health", "info"), useful for custom endpoints such as the network one in section 14.
  • anyRequest().hasRole("ADMIN") closes off the rest. Inside this chain anyRequest() means "any Actuator endpoint", because the securityMatcher has already narrowed the scope.
  • httpBasic instead of JWT, deliberately: operations tooling — Prometheus in 09-04, an on-call curl, the alerting system — has no idea how to request a token from /api/v1/auth/login or renew it. A technical user with ADMIN and a long password, on a port that never leaves the internal network and over TLS, is more operable and no less secure. The mature alternative is mTLS between the monitoring system and the application.
  • STATELESS and CSRF disabled, for consistency with the rest of the project (05-02): there is no session and there are no forms.

The RoleHierarchy from 05-03 still applies, so an ADMIN also covers whatever OPERATOR is allowed. And the permitAll on health is open to review: if the network allows it, closing health too and leaving only the per-group probes public (section 8) is better still.

  1. /actuator/health in depth

health is not a check, it is an aggregation: Spring collects every bean implementing HealthIndicator, asks each one and combines the results.

management:
  endpoint:
    health:
      show-details: when-authorized      # never | when-authorized | always
      show-components: when-authorized
      roles: ADMIN
  health:
    diskspace:
      enabled: false                     # noisy in containers with ephemeral disks
Value of show-details Who sees the breakdown
never (default) Nobody: just {"status":"UP"}
when-authorized Only authenticated users holding one of the roles in roles
always Everybody, anonymous callers included

With when-authorized and ADMIN credentials, the response changes:

{
  "status": "UP",
  "components": {
    "db":        { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
    "diskSpace": { "status": "UP", "details": { "total": 494384795648, "free": 91234567890 } },
    "stationNetwork": { "status": "UP", "details": { "stations": 4, "availableBikes": 37 } }
  }
}

The automatic indicators appear on their own depending on what is on the classpath: db with any DataSource (it asks for a connection and runs isValid()), diskSpace always (free space above a 10 MB threshold), ping always (answers UP while the application is serving), ssl from Boot 3.4 onwards (certificates about to expire) and one per infrastructure starter present: mail, redis, rabbit, kafka, elasticsearch. Each is switched off with management.health.<name>.enabled: false.

The aggregation rules are the part that produces the most surprises: UP yields 200, DOWN and OUT_OF_SERVICE yield 503, and UNKNOWN yields 200. The global status is the worst of them all according to a configurable order, and any DOWN brings the whole thing down. Hence this lesson's most common trap: an indicator for a non-critical system can pull the application out of production. If CicloUrbana declares an indicator for the Ribalta payment gateway and the gateway goes down, /actuator/health returns 503, the load balancer withdraws the instance and the entire network stops renting bikes over a problem that only affected billing. The fix is not to drop the indicator, but to take it out of the group the load balancer looks at.

  1. Health groups and availability probes

A group is a subset of indicators with its own URL and its own policy:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: db
          show-details: always
        liveness:
          include: livenessState

Now /actuator/health/readiness and /actuator/health/liveness exist, each aggregating only its own members. The payment gateway from the previous example can keep appearing in the full /actuator/health — where the team sees it — without being in readiness, which is what the load balancer looks at.

Availability probes are the piece we will need in 07-04 and 08-04. With probes.enabled: true — and automatically when Spring detects it is running on Kubernetes — the framework manages two states of its own:

Probe Question If it fails, the orchestrator... Typical causes
Liveness Is the process healthy or beyond recovery? Kills and restarts the container Corrupt internal state, total deadlock, OutOfMemoryError
Readiness Can it serve requests right now? Stops sending it traffic, without killing it Starting up, warming caches, critical dependency down, maintenance
sequenceDiagram
    participant K as Orchestrator
    participant A as CicloUrbana
    K->>A: GET /actuator/health/liveness
    A-->>K: 503 (no server yet)
    Note over A: Context ready · ApplicationReadyEvent
    K->>A: GET /actuator/health/liveness
    A-->>K: 200 UP (process healthy)
    K->>A: GET /actuator/health/readiness
    A-->>K: 503 (Flyway still migrating)
    K->>A: GET /actuator/health/readiness
    A-->>K: 200 UP
    K->>K: Adds the instance to the load balancer

The distinction is critical and it is got wrong constantly. If you put the database in liveness, a thirty-second PostgreSQL outage will make the orchestrator kill every instance of CicloUrbana, which on restart will not find the database either, entering a restart loop that turns a database incident into a total outage. The rule is simple: liveness holds only what a restart fixes; readiness holds everything that prevents serving properly. A downed external dependency is never fixed by restarting.

  1. A custom HealthIndicator: StationNetworkHealth

CicloUrbana's health is not purely technical. If all four Ribalta stations have no bikes available, the application responds perfectly and the service, in practice, does not exist. That is a business indicator:

package com.ciclourbana.stations;

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

@Component("stationNetwork")
public class StationNetworkHealth implements HealthIndicator {

    private final StationRepository stationRepository;
    private final BikeRepository bikeRepository;

    // constructor omitted

    @Override
    public Health health() {
        try {
            long stations   = stationRepository.count();
            long available  = bikeRepository.countByStatus(BikeStatus.AVAILABLE);
            long withBikes  = stationRepository.countWithAvailableBikes();

            Health.Builder health = available == 0
                    ? Health.down().withDetail("reason", "no bike available anywhere in the network")
                    : Health.up();

            return health.withDetail("stations", stations)
                         .withDetail("stationsWithBikes", withBikes)
                         .withDetail("availableBikes", available)
                         .build();

        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

Four details that separate a useful indicator from a harmful one:

  • The bean name is the component name. @Component("stationNetwork") produces that key in the JSON. Without it, Spring derives it from the class by stripping the HealthIndicator suffix; naming it explicitly stops a rename from breaking the readiness group configuration.
  • Never let an exception escape. An indicator that throws produces a DOWN with the exception message inside the JSON, and that message may contain the database URL along with the username.
  • It has to be fast. It runs on every probe, every three or five seconds, on every instance. A query that walks the rental history turns the probe into a performance problem; if the calculation is expensive, cache it (09-02) or read it from the in-memory ActiveRentalsRegistry.
  • Think about which group it belongs to. stationNetwork in readiness means: if Ribalta runs out of bikes, the load balancer withdraws the instance. That is almost certainly not what you want — a citizen must still be able to browse stations even when they are empty — so it fits better in the general /actuator/health and in an alert. It is the perfect illustration of why the question "which group does it go in?" matters more than the code itself.

Variants: AbstractHealthIndicator saves you the try/catch by overriding doHealthCheck(Health.Builder); CompositeHealthContributor groups several sub-indicators under a common key — handy for per-station health — and in a WebFlux application you implement ReactiveHealthIndicator, whose health() returns a Mono<Health> so as not to block the event loop. CicloUrbana is servlet-based, so we use the blocking variant.

  1. Changing availability with AvailabilityChangeEvent

Sometimes the state is not inferred, it is decided. When the Ribalta team is about to apply a heavy migration, they want the instance to stay alive but stop receiving traffic. Spring models this with two enums, LivenessState and ReadinessState, changed by publishing an event:

@Service
public class MaintenanceMode {

    private final ApplicationEventPublisher events;   // constructor omitted

    public void enable() {
        AvailabilityChangeEvent.publish(events, this, ReadinessState.REFUSING_TRAFFIC);
    }

    public void disable() {
        AvailabilityChangeEvent.publish(events, this, ReadinessState.ACCEPTING_TRAFFIC);
    }

    @EventListener
    public void onChange(AvailabilityChangeEvent<ReadinessState> event) {
        log.warn("Availability of the Ribalta network: {}", event.getState());
    }
}

From the publish onwards, /actuator/health/readiness returns 503 and the load balancer withdraws the instance without killing it, while liveness stays UP; in-flight requests finish thanks to the graceful shutdown from 01-05. Publishing LivenessState.BROKEN has the opposite and far more drastic effect: it tells the orchestrator "I am beyond recovery, kill me". The @EventListener records in the log who stopped accepting traffic and when, which is exactly what you go looking for afterwards during an investigation.

  1. /actuator/info: which version is deployed

Of all the operations questions, "which version is deployed?" is the one that wastes the most time when it has no automatic answer. info answers it, but it comes empty by default and has to be filled from three sources: static properties, build data and the Git commit.

management:
  info:
    env:   { enabled: true }        # disabled by default since Boot 2.6
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    java:  { enabled: true }
info:
  application: { name: CicloUrbana, city: Ribalta }

The two plugins that generate META-INF/build-info.properties and git.properties during packaging:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution><id>build-info</id><goals><goal>build-info</goal></goals></execution>
    </executions>
</plugin>
<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>
    <configuration><generateGitPropertiesFile>true</generateGitPropertiesFile></configuration>
</plugin>

The combined result:

{
  "application": { "name": "CicloUrbana", "city": "Ribalta" },
  "build": { "artifact": "ciclourbana", "version": "2.4.0", "time": "2026-09-01T07:12:44.118Z" },
  "git":   { "branch": "main", "commit": { "id": "9f3a2b1", "time": "2026-09-01T07:10:02Z" } }
}

With that response, "is the fare calculation fix deployed?" is answered by comparing a seven-character hash, without logging into the machine. In 08-05 this becomes the basis of post-deployment verification. A custom InfoContributor additionally adds data computed on the fly:

@Component
public class RibaltaNetworkInfo implements InfoContributor {

    private final StationRepository stationRepository;
    private final NetworkProperties network;               // constructor omitted

    @Override
    public void contribute(Info.Builder builder) {
        builder.withDetail("network", Map.of(
                "city", network.city(),
                "stations", stationRepository.count(),
                "maximumRentalDuration", network.maximumRentalDuration().toString()));
    }
}

Warning: info usually stays public, so do not put anything in it that you would not put on a billboard. The database URL, the server name or the list of dependencies with their versions — a perfect inventory of known vulnerabilities — must not appear.

  1. /actuator/loggers: changing the log level on the fly

This is the endpoint that rescues an on-call shift most often. It lets you raise the log detail of one specific package without restarting and without deploying, and lower it again when you are done.

# Query the effective level
curl -s -u admin:*** http://localhost:8081/actuator/loggers/com.ciclourbana.rentals
# {"configuredLevel":null,"effectiveLevel":"INFO","members":[...]}

# Raise just that package to DEBUG
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.rentals

# Put it back to its configured value
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":null}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.rentals

Three operational tips. Be surgical: setting org.hibernate.SQL to DEBUG on an instance with real traffic can generate gigabytes in minutes and fill the disk, which really is an outage. Remember to undo it, because the change lives in memory and survives until the next restart, which may be weeks away. And remember it is a write endpoint: that is exactly why it demands ADMIN. The systematic treatment of logging — formats, structured JSON, aggregation — belongs to 09-05; here we only care about the lever.

  1. mappings, configprops, env and secrets

/actuator/mappings returns the complete route inventory: the pattern, the HTTP methods, the produces/consumes and the Java method that serves it. It answers in seconds "does this route exist?", "why is this request landing in the wrong controller?" and "is there any debug route still published?".

/actuator/configprops shows the @ConfigurationProperties from 02-05 with their effective values — NetworkProperties, FareProperties, GatewayProperties — and, together with /actuator/env, answers the most frustrating question in Spring configuration: where does this value come from? env lists the sources in precedence order, so you can see that the password comes from an environment variable and not from the YAML.

And here is the risk: both endpoints see everything, including ciclourbana.jwt.secret and spring.datasource.password. That is why Spring sanitises:

management:
  endpoint:
    env:         { show-values: when-authorized }   # never (default) | when-authorized | always
    configprops: { show-values: when-authorized }

With never, every value appears as "******". With when-authorized they are shown only to whoever holds the configured roles; and even then Spring masks keys whose name matches sensitive patterns (password, secret, key, token, credentials), masking that can be tuned with a SanitizingFunction bean.

The Ribalta rule: env and configprops are not exposed in production. The diagnostics they provide are real, but you can get them in pre with the same configuration, and no amount of sanitisation makes up for the day somebody adds a property called ciclourbana.gateway.master-credential — which matches no pattern — and publishes it in JSON. Masking is a second line of defence, not the first.

  1. A custom endpoint with @Endpoint

When the information operations needs fits neither health nor info, you write an endpoint. In CicloUrbana, the summary of the state of the Ribalta network:

package com.ciclourbana.stations;

import org.springframework.boot.actuate.endpoint.annotation.*;   // Endpoint, ReadOperation...

@Component
@Endpoint(id = "network")
public class NetworkEndpoint {

    private final StationService stationService;
    private final MaintenanceMode maintenance;            // constructor omitted

    /** GET /actuator/network */
    @ReadOperation
    public Map<String, Object> summary() {
        List<Station> stations = stationService.listAll();
        return Map.of(
                "timestamp", Instant.now().toString(),
                "stations", stations.size(),
                "totalDocks", stations.stream().mapToInt(Station::capacity).sum(),
                "detail", stations.stream().map(s -> Map.of(
                        "name", s.name(),
                        "available", stationService.countAvailable(s.id()))).toList());
    }

    /** GET /actuator/network/{name} */
    @ReadOperation
    public Map<String, Object> stationDetail(@Selector String name) {
        return stationService.summaryByName(name);
    }

    /** POST /actuator/network  with body {"maintenance": true} */
    @WriteOperation
    public Map<String, Object> setMaintenance(boolean maintenance) {
        if (maintenance) this.maintenance.enable(); else this.maintenance.disable();
        return Map.of("maintenance", maintenance);
    }
}
Annotation HTTP verb Notes
@ReadOperation GET There can be several if they are distinguished by @Selector
@WriteOperation POST Parameters are read from the JSON body by name
@DeleteOperation DELETE For invalidation or clean-up operations
@Selector — Turns a parameter into a path segment: /actuator/network/{name}

Four points to bear in mind. The id must be lowercase alphanumeric and must not clash with another endpoint. You have to expose it explicitly in exposure.include, just like the built-in ones. An endpoint is not a controller: it does not go through the GlobalExceptionHandler from 03-06 nor the validation from 03-04, so validate the parameters yourself and return simple structures. And @Endpoint publishes over HTTP and JMX at once; to restrict it to one there are @WebEndpoint and @JmxEndpoint. The fact that this endpoint can switch maintenance on with a POST explains why the chain in section 6 demands ADMIN for everything that is not health or info.

  1. Actuator over JMX

As well as over HTTP, Actuator publishes its endpoints as MBeans under the org.springframework.boot domain, although it is disabled by default since Spring Boot 2.2:

management:
  endpoints:
    jmx:
      exposure: { include: health,info,network }
spring:
  jmx: { enabled: true }

With that, JConsole, VisualVM or JMC connected to the process see the endpoints as invokable operations. It is still useful in traditional deployments on machines reachable over SSH, but in containers the HTTP route is the usual one: it does not require opening the RMI port nor wrestling with JMX's random port negotiation, notoriously hostile to NAT and to Docker.

  1. The warning that closes the lesson

Actuator is, quite literally, a service door into the application. With env you read secrets, with heapdump you download the entire heap with the Ribalta citizens' JWTs inside, with loggers you fill the disk and with shutdown you switch the service off. An Actuator exposed unauthenticated to the internet is not a bad practice: it is a breach. Search engines specialising in exposed devices index /actuator/env routes systematically, and they find results every single day.

The minimum checklist before every deployment to Ribalta:

  1. exposure.include is an explicit list, never *, in the prod profile.
  2. env, heapdump, threaddump, beans and configprops are excluded or restricted to ADMIN, and shutdown remains disabled.
  3. There is a chain with EndpointRequest.toAnyEndpoint() and anyRequest() is not left without a rule.
  4. management.server.port is a port closed at the firewall to external traffic, and show-details and show-values are not set to always.
  5. Everything travels over TLS, because basic authentication sends the password encoded, not encrypted.

Common Mistakes and Tips

Exposing everything with include: "*" and forgetting about it. It works on day one, gets copied into the production profile and survives for years. Write the explicit list from the start.

Putting the database in liveness. It turns a PostgreSQL outage into a restart loop across every instance. liveness holds only what a restart fixes.

A slow HealthIndicator. It runs on every probe of every instance: a two-second query turns the health check into a performance problem and causes false DOWNs through timeouts.

A non-critical indicator that brings down the global health. The aggregate status is the worst of them all: if the payment gateway should not pull the instance out of the load balancer, take it out of the readiness group.

Confusing enabled with exposure. Disabling health "so it cannot be seen" also breaks the internal probes. What you want is to leave it enabled and not expose it, or expose it protected.

Tip: put Actuator on its own port from day one, because changing it later means touching load balancers, firewalls, alerts and manifests. And use EndpointRequest, never the literal path: "/actuator/**" stops matching the day somebody changes base-path, and security disappears without a single error in the log.

Tip: document your health groups — what the load balancer looks at, what the orchestrator looks at, what fires an alert — and write a test (06-04) that requests /actuator/env without credentials and expects 401: that way the exposure policy is defended by the build.

Exercises

Exercise 1: a complete operational configuration

Configure Actuator for CicloUrbana to these requirements: management port 8081; exposure of health, info, metrics, loggers, flyway and the custom network endpoint; health details only for authorised users with the ADMIN role; availability probes enabled; a readiness group containing only the database; the disk space indicator disabled; and info with build and Git data. Write the YAML and the security chain, and explain what each URL answers to an anonymous client.

Exercise 2: a connection pool health indicator

Write a HealthIndicator named connectionPool that queries the HikariDataSource from 04-02 through its HikariPoolMXBean and returns DOWN if more than three threads are waiting for a connection or if active connections exceed 90 % of the maximum, and UP otherwise, with the breakdown as details. Decide, with reasons, which health group it should go in.

Exercise 3: reviewing a dangerous configuration

A colleague has pushed this production configuration. Find every problem and propose the fix.

management:
  endpoints:
    web:
      exposure: { include: "*" }
  endpoint:
    health:
      show-details: always
      group:
        liveness: { include: db, paymentGateway }
    shutdown: { enabled: true }
    env: { show-values: always }
@Bean
@Order(1)
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/actuator/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
    return http.build();
}

Solutions

Solution 1

management:
  server:
    port: 8081
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info,metrics,loggers,flyway,network
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      roles: ADMIN
      probes: { enabled: true }
      group:
        readiness: { include: db }
        liveness:  { include: livenessState }
  health:
    diskspace: { enabled: false }
  info:
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    env:   { enabled: true }

info:
  application: { name: CicloUrbana, city: Ribalta }

With the security chain from section 6, what an anonymous client sees on port 8081 is:

URL Anonymous response Reason
/actuator/health 200 with {"status":"UP"} Public, but show-details: when-authorized hides the breakdown
/actuator/health/readiness 200 or 503 depending on the state of db It belongs to health, also public
/actuator/info 200 with version, commit and network data Public by explicit decision
/actuator/metrics 401 Exposed but requires ADMIN
/actuator/loggers 401 Likewise, and it is a write endpoint on top
/actuator/network 401 Custom endpoint, covered by toAnyEndpoint()
/actuator/env 404 Not in include: the route does not exist
/actuator/beans 404 Not exposed either

The difference between 401 and 404 is informative: 404 means "not exposed" and 401 means "exposed and protected". And it is worth remembering that these URLs are only reachable from the internal network, because port 8081 is not published externally.

Solution 2

@Component("connectionPool")
public class ConnectionPoolHealth implements HealthIndicator {

    private static final int MAX_WAITING = 3;
    private static final double USAGE_THRESHOLD = 0.90;

    private final HikariDataSource dataSource;            // constructor omitted

    @Override
    public Health health() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        if (pool == null) {
            return Health.unknown().withDetail("reason", "pool not initialised yet").build();
        }

        int active  = pool.getActiveConnections();
        int waiting = pool.getThreadsAwaitingConnection();
        int maximum = dataSource.getMaximumPoolSize();

        boolean saturated = waiting > MAX_WAITING
                || (double) active / maximum > USAGE_THRESHOLD;

        return (saturated ? Health.down() : Health.up())
                .withDetail("active", active)
                .withDetail("idle", pool.getIdleConnections())
                .withDetail("waiting", waiting)
                .withDetail("maximum", maximum)
                .withDetail("usage", Math.round(100.0 * active / maximum) + "%")
                .build();
    }
}

Decisions. We inject HikariDataSource rather than DataSource, because the MXBean is Hikari-specific; the initial null is handled because the pool is created lazily and a very early probe may arrive first. In that case we return UNKNOWN and not DOWN, because UNKNOWN maps to 200 and a freshly started instance should not declare itself sick. The thresholds are constants for brevity; in the real project they would be @ConfigurationProperties (02-05) tunable per environment. The indicator is cheap: it only reads in-memory counters, without touching the database.

Which group it goes in. This one really is a good candidate for readiness, and it is the exact counterpoint to stationNetwork. A saturated pool means the instance cannot serve more requests properly, so withdrawing it from the load balancer is the right call: traffic goes to healthy instances and this one recovers by draining its queue. Putting it in liveness would be a serious mistake — a load spike would kill the very instances that are holding on. A final caution: if every instance saturates its pool at the same time, they all declare themselves REFUSING_TRAFFIC and the Ribalta network is left with none available; that is why the threshold must be high (90 %, not 60 %) and why an alert must fire long before the probe acts.

Solution 3

Seven problems, in order of severity:

# Problem Consequence Fix
1 permitAll() over the whole of Actuator Anyone on the internet reads env, downloads heapdump and shuts the application down EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class).permitAll() and anyRequest().hasRole("ADMIN")
2 shutdown.enabled: true An anonymous POST switches CicloUrbana off Remove it: leave it disabled
3 include: "*" Publishes env, beans, heapdump, threaddump and any future endpoint An explicit list
4 env.show-values: always The JWT secret and the PostgreSQL password come out in plain text never, or simply do not expose env
5 paymentGateway inside liveness A gateway outage kills and restarts every instance in a loop Take it out of liveness; it should not even be in readiness
6 db inside liveness The same restart loop on any database incident Move it to readiness
7 show-details: always The breakdown (engine, version, disk paths, pool state) is public when-authorized with roles: ADMIN

There is an eighth problem, of form: the chain uses the literal path "/actuator/**", so if somebody changes base-path or enables management.server.port, the rule stops matching without any warning.

The corrected configuration is the one from solution 1 plus the chain from section 6. And there is an underlying lesson: problems 1 and 2 together let anyone switch off a city's bike network with a one-line curl. It is not a Spring Boot flaw — all of its defaults are safe — it is the result of having changed them one by one in search of convenience during development and having promoted the file to production. Hence the next module.

Conclusion

CicloUrbana now lets itself be questioned. You know what it means for an application to be operable and why that is a different property from being correct. You know the full catalogue of Actuator endpoints along with the risk of each one, you understand why only health goes out over HTTP out of the box and why that policy was born of a lesson learned the hard way. You know how to expose them with include/exclude, move them to a different base-path and, above all, separate them onto their own port so that the firewall — and not a rule in code — decides who talks to management. You have closed the security chain with EndpointRequest.toAnyEndpoint(), which does not expire when the path changes, leaving only health and info public and demanding ADMIN for the rest.

You have mastered /actuator/health from the inside: aggregation to the worst status, show-details, the automatic indicators and how to switch them off, and the piece that will be needed in Docker and in Kubernetes — the health groups and the liveness and readiness probes — with the rule that avoids the most expensive mistake: liveness holds only what a restart fixes. You have written StationNetworkHealth, which measures the health of the service and not just of the process, with the four cautions that separate a useful indicator from a harmful one, and you know how to change availability at will with AvailabilityChangeEvent to drain traffic from an instance without killing it. /actuator/info finally answers which version and which commit are deployed in Ribalta, /actuator/loggers raises the log detail without restarting, mappings and configprops serve as diagnostic tools, and you have the warning about env and secrets committed to memory. And you have written your own @Endpoint(id = "network") with read, write and delete operations.

One endpoint has been deliberately left unopened: /actuator/metrics. Actuator publishes it, but exploiting it — business counters and timers, @Timed, percentiles, MeterRegistry — is the job of 09-03, and its collection by Prometheus and its display in Grafana, the job of 09-04. Here we have built the instrumentation; there it will be read.

And now the problem that exercise 3 laid bare comes into view. All the configuration in this lesson — what is exposed, how many details are shown, whether shutdown is enabled — must be different on a developer's laptop and on the council's server. The same goes for the database (H2 versus PostgreSQL), for the log level, for Swagger, for ddl-auto, for CORS and for JWT expiry. Until now we have been changing them by hand in a single application.yml, which is exactly how a development configuration ends up deployed in production. The next lesson, Spring Boot Profiles, solves that with the principle of build once, deploy many: one artefact, several environments, no recompilation.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved