DEV Community

Ankit Verma
Ankit Verma

Posted on

Actuator: health / metrics / info

A running app is a black box

Once your Spring Boot app leaves your laptop and lands on a server, it becomes a process you can't see into. From the outside, you can't tell whether it's healthy, how much memory it's burning, or even which build of the code is actually running. Then something breaks at 3 a.m., someone needs answers fast, and SSH-ing onto the box to poke around is slow and risky.

Spring Boot Actuator is the module that fixes this. It bolts a set of ready-made operational features onto your app — standard ways to inspect and monitor it while it runs — and serves them over HTTP so that tools and humans can ask the app about itself. You write none of it. You add one dependency, and the features appear.

You meet Actuator the day your app goes to production: the load balancer needs a health check, the dashboards need metrics, and the on-call engineer needs to know what version is live. This article walks the three endpoints you'll reach for first — health, metrics, and info — and finishes with how to expose them without handing the world a window into your app.

Turning it on

Everything starts with one starter dependency:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Boot's auto-configuration notices the starter on the classpath and wires everything up for you — no @Bean definitions, no config class. Each operational feature it registers is called an endpoint: a small, named unit of functionality (like health or metrics) that you can reach at a URL under a common base path, /actuator.

Two separate switches govern every endpoint, and mixing them up is a classic source of confusion. An endpoint is enabled (it exists and does its work) and, separately, exposed (it's reachable over HTTP). Most endpoints are enabled by default, but for safety Boot only exposes health over HTTP out of the box. We'll open up the others deliberately at the end.

With the starter added, hit the base path and the app lists what's on offer:

GET /actuator
Enter fullscreen mode Exit fullscreen mode

Now let's walk the three you'll use daily.

Health: is the app OK?

The health endpoint answers the single most-asked question in operations: is this thing working?

GET /actuator/health

{ "status": "UP" }
Enter fullscreen mode Exit fullscreen mode

That UP is not a guess. Actuator ships small probes called health indicators, and it auto-registers one for each piece of infrastructure it detects — a datasource, a message broker, disk space, and so on. Each indicator checks its own dependency and reports UP or DOWN. A database indicator, for example, runs a trivial validation query; if the query fails, that indicator goes DOWN.

The endpoint then aggregates all indicators into the one top-level status, and the rule is simple: the worst status wins. One DOWN indicator drags the whole app to DOWN, which is exactly what a load balancer wants — a mostly-working app that can't reach its database is not ready to serve traffic.

By default you only see the rolled-up status, not the per-indicator breakdown. To see the details, turn them on:

management:
  endpoint:
    health:
      show-details: always
Enter fullscreen mode Exit fullscreen mode

Now the response names each contributor:

{
  "status": "UP",
  "components": {
    "db":        { "status": "UP" },
    "diskSpace": { "status": "UP" }
  }
}
Enter fullscreen mode Exit fullscreen mode

When the built-in indicators aren't enough, you write your own by implementing the HealthIndicator interface. Say your service depends on an external payment gateway and you want its reachability reflected in your health:

@Component
public class PaymentGatewayHealth implements HealthIndicator {

    private final PaymentGatewayClient client;

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

    @Override
    public Health health() {
        if (client.ping()) {
            return Health.up().withDetail("gateway", "reachable").build();
        }
        return Health.down().withDetail("gateway", "unreachable").build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Because it's a @Component, component scanning picks it up and Actuator folds it into the aggregate automatically. The bean name (paymentGatewayHealthpaymentGateway) becomes its key in the response.

Liveness and readiness

Under an orchestrator like Kubernetes, "is it OK?" splits into two genuinely different questions, and answering them with one signal causes real outages.

Liveness asks: is this app broken beyond repair? If the answer is no, restarting it is the fix. Readiness asks: can it take traffic right this second? An app can be perfectly alive yet not ready — still warming a cache, or briefly waiting on a dependency. The distinction matters because the wrong reaction is harmful: restart an app that's only temporarily not ready and you kill a healthy process; keep routing traffic to an app that's fatally broken and every request fails.

Actuator exposes these as health groups — named subsets of indicators — once you enable the probes:

management:
  endpoint:
    health:
      probes:
        enabled: true
Enter fullscreen mode Exit fullscreen mode

You now get two extra URLs, /actuator/health/liveness and /actuator/health/readiness, each aggregating only its own group. You point Kubernetes' liveness probe at the first and its readiness probe at the second, and each gets the answer it should act on.

Metrics: how is it behaving?

Health is a yes/no. Metrics are the numbers over time — memory used, request latency, how many orders were placed. Actuator collects these through a library called Micrometer.

Micrometer is worth understanding for one reason: it's a facade. Just as SLF4J lets you write logging calls without committing to a specific logging backend, Micrometer lets your app record measurements without committing to a specific monitoring system. Your code talks to Micrometer; Micrometer ships the numbers to whatever registry you plug in — Prometheus, Datadog, CloudWatch. Swap the backend by swapping a dependency, not by rewriting your code.

The endpoint first lists the metric names it knows about:

GET /actuator/metrics

{ "names": ["jvm.memory.used", "http.server.requests", "system.cpu.usage", ...] }
Enter fullscreen mode Exit fullscreen mode

To read one, drill into it by name:

GET /actuator/metrics/jvm.memory.used
Enter fullscreen mode Exit fullscreen mode

The response carries the current measurement plus a list of tags — dimensions you can slice the number by. A single metric like http.server.requests is tagged with uri, method, and status, so instead of one blurry total you can ask a precise question:

GET /actuator/metrics/http.server.requests?tag=status:500
Enter fullscreen mode Exit fullscreen mode

That returns only the requests that ended in a 500 — request counts and timings for your failing calls alone.

Recording your own metric means asking Micrometer's registry for a meter. A counter — a value that only ever goes up — is the simplest:

@Service
public class OrderService {

    private final Counter ordersPlaced;

    public OrderService(MeterRegistry registry) {
        this.ordersPlaced = registry.counter("orders.placed");
    }

    public void placeOrder(Order order) {
        // ... business logic ...
        ordersPlaced.increment();
    }
}
Enter fullscreen mode Exit fullscreen mode

MeterRegistry is a bean Actuator has already put in the context, so you just inject it. Now orders.placed shows up alongside the built-in metrics, sliceable and shippable like the rest.

In practice you rarely poll /actuator/metrics by hand. You add micrometer-registry-prometheus, which lights up a /actuator/prometheus endpoint, and a Prometheus server periodically scrapes that URL — pulls the current values on a schedule — into a time-series database your dashboards read from. Your app's only job is to expose the numbers; the monitoring stack does the collecting.

Info: what is this, exactly?

The info endpoint answers the on-call engineer's first question: what am I even looking at?

GET /actuator/info
Enter fullscreen mode Exit fullscreen mode

Out of the box it returns {}, because info is assembled from info contributors, and none carry data until you give them some. The quickest is static properties — anything you nest under an info key in your configuration is surfaced verbatim:

info:
  app:
    name: Orders Service
    team: Payments
Enter fullscreen mode Exit fullscreen mode

The more valuable contributors are generated at build time. Point the spring-boot-maven-plugin at its build-info goal and it writes the app's version and build timestamp into a file Actuator reads; add the git-commit-id plugin and the exact commit hash of the running code appears too. Now /actuator/info tells you precisely which build is live — no guessing whether the deploy actually went through.

Exposing endpoints — and the trap in doing it

Remember the enabled-versus-exposed split. Everything above is enabled, but over HTTP only health is exposed by default. To serve the others, list them explicitly:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
Enter fullscreen mode Exit fullscreen mode

It's tempting to write include: "*" and be done. Don't — that's the trap. Actuator ships more than the three friendly endpoints. env dumps your configuration (potentially including secrets), heapdump downloads a full memory snapshot, loggers lets a caller change log levels at runtime, shutdown can stop the app. Exposing all of that on a public port hands an attacker a detailed map of your system, and sometimes the keys.

Two defenses, ideally together. First, if the app already uses Spring Security, its filter chain protects the /actuator/** paths like any other URL — require an authenticated admin role to reach them. Second, move the whole management surface onto its own port:

management:
  server:
    port: 9001
Enter fullscreen mode Exit fullscreen mode

Actuator endpoints now live on 9001 while your API stays on the main port. You firewall 9001 off from the public internet, so only your monitoring systems inside the network can reach it — the numbers flow to your dashboards, and no further.

Where this leads

Actuator turns the black box into something you can question while it runs: is it healthy (health), how is it behaving (metrics), and what is it (info) — with one dependency and a little safe configuration. That's the app observing itself once it's deployed. The natural next question is how the app gets packaged to be deployed in the first place — how Boot bundles everything into a runnable jar, and what a layered jar buys you — which is exactly where we go next.

Top comments (0)