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
- What it means for an application to be operable
- Adding Actuator and what shows up straight away
- The full endpoint catalogue
- Exposure and discovery
- A separate management port
- Securing Actuator with Spring Security
/actuator/healthin depth- Health groups and availability probes
- A custom
HealthIndicator:StationNetworkHealth - Changing availability with
AvailabilityChangeEvent /actuator/info: which version is deployed/actuator/loggers: changing the log level on the flymappings,configprops,envand secrets- A custom endpoint with
@Endpoint - Actuator over JMX
- Common Mistakes and Tips
- Exercises
- 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.
- 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:
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:
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.
- 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.
- 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.
- A separate management port
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.
- 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 thebase-pathand 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 thenetworkone in section 14.anyRequest().hasRole("ADMIN")closes off the rest. Inside this chainanyRequest()means "any Actuator endpoint", because thesecurityMatcherhas already narrowed the scope.httpBasicinstead of JWT, deliberately: operations tooling — Prometheus in 09-04, an on-callcurl, the alerting system — has no idea how to request a token from/api/v1/auth/loginor renew it. A technical user withADMINand 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.STATELESSand 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.
/actuator/health in depth
/actuator/health in depthhealth 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 disksValue 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.
- 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: livenessStateNow /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.
- A custom
HealthIndicator: StationNetworkHealth
HealthIndicator: StationNetworkHealthCicloUrbana'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 theHealthIndicatorsuffix; naming it explicitly stops a rename from breaking thereadinessgroup configuration. - Never let an exception escape. An indicator that throws produces a
DOWNwith 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.
stationNetworkinreadinessmeans: 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/healthand 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.
- Changing availability with
AvailabilityChangeEvent
AvailabilityChangeEventSometimes 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.
/actuator/info: which version is deployed
/actuator/info: which version is deployedOf 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.
/actuator/loggers: changing the log level on the fly
/actuator/loggers: changing the log level on the flyThis 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.rentalsThree 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.
mappings, configprops, env and secrets
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.
- A custom endpoint with
@Endpoint
@EndpointWhen 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.
- 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.
- 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:
exposure.includeis an explicit list, never*, in theprodprofile.env,heapdump,threaddump,beansandconfigpropsare excluded or restricted toADMIN, andshutdownremains disabled.- There is a chain with
EndpointRequest.toAnyEndpoint()andanyRequest()is not left without a rule. management.server.portis a port closed at the firewall to external traffic, andshow-detailsandshow-valuesare not set toalways.- 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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
