OnCallReady

Lesson 21.1 · Spring Boot Runtime, Resilience & Python Ops · 15 min read

Actuator: the seam between the app and the platform

In plain words

Imagine a car's dashboard and the diagnostic plug under the steering wheel. The driver sees the speed and a few warning lights; the mechanic plugs in and reads everything: engine temperature, error codes, the exact settings. Nobody has to open the engine to find out what's wrong.

Spring Boot Actuator is that plug for a Java service. It adds endpoints like /actuator/health, /actuator/prometheus, /actuator/env and /actuator/threaddump, so Kubernetes probes, monitoring tools and you can ask the app about itself over HTTP, without a shell. By default only health is exposed; the rest must be switched on with management.endpoints.web.exposure.include, and some of them are powerful enough that they belong on a private management port.

What Actuator is

The problem. The Java services on this box (orders, payments) and in the cluster are black boxes to the platform: is it healthy, which config value won, what are its threads doing? Opening a shell inside the container is slow or impossible. Spring Boot apps can answer those questions over HTTP - if someone switches it on.

What you need to know already: the JVM, thread dumps and heap dumps (20.1, 20.20, 20.28), probes (17.20, 17.22), curl and HTTP status codes (9.21), jq (7.11).

A few words first, for someone who has never written Java:

Actuator (spring-boot-starter-actuator) adds operational endpoints to a Spring Boot app: health, metrics, environment, thread dumps, heap dumps, loggers. It is how the platform - Kubernetes probes, monitoring tools, you at 3am - talks to the app without a shell. From the platform side it is the most important API the developers ship.

Exposure: only health, by default

Endpoints exist, but over HTTP only health is exposed out of the box (since Boot 2.5). The rest return 404 until someone opts in. (curl -s URL | jq . = fetch quietly and pretty-print the JSON; the _links list is what is exposed.)

$ curl -s localhost:8080/actuator | jq .
{
  "_links": {
    "self":        { "href": "http://localhost:8080/actuator", "templated": false },
    "health":      { "href": "http://localhost:8080/actuator/health", "templated": false },
    "health-path": { "href": "http://localhost:8080/actuator/health/{*path}", "templated": true }
  }
}
$ curl -s localhost:8080/actuator/prometheus
{"timestamp":"2026-09-23T10:00:04.605+00:00","status":404,"error":"Not Found","path":"/actuator/prometheus"}

Exposure is configuration:

management.endpoints.web.exposure.include=health,info,metrics,prometheus,threaddump,env
management.endpoints.web.exposure.exclude=env        # exclude wins over include
management.endpoints.web.exposure.include=*          # everything - think twice

After a restart the startup log tells you what it did:

o.s.b.a.e.web.EndpointLinksResolver      : Exposing 6 endpoints beneath base path '/actuator'

The endpoints you will use

/actuator/health               UP/DOWN, and the liveness/readiness groups
/actuator/info                 build and git info, if the app adds it
/actuator/metrics              list of meter names;  /actuator/metrics/{name}?tag=k:v
/actuator/prometheus           every counter and timer as a plain-text page any monitoring tool can read (21.11)
/actuator/env                  every property source and where each value came from
/actuator/env/{name}           one property, across all sources
/actuator/configprops          the app's settings objects ("beans", see below) and the values they got
/actuator/loggers              read and CHANGE log levels at runtime (POST)
/actuator/threaddump           thread dump: JSON by default, text with Accept: text/plain
/actuator/heapdump             an HPROF heap dump, streamed to you
/actuator/circuitbreakers      circuit breaker state (21.22), when the Resilience4j library is present

(A bean is an object Spring creates and manages for the app - a database pool, an HTTP client, a settings holder. Actuator can list them all.)

The thread dump over HTTP is the one to remember when the image has no JDK (so no jcmd, 20.3). -H 'Accept: text/plain' asks for the classic text format instead of JSON:

# once threaddump is exposed (the next mission does that)
curl -s -H 'Accept: text/plain' localhost:8080/actuator/threaddump | head -5
2026-09-23 10:00:08
Full thread dump OpenJDK 64-Bit Server VM (21.0.8+9-Ubuntu-0ubuntu1~26.04 mixed mode, sharing):
...
curl -s localhost:8080/actuator/threaddump | jq -r '.threads[].threadState' | sort | uniq -c
     10 RUNNABLE
      4 TIMED_WAITING
     32 WAITING

And the heap dump - a binary; do not let it hit your terminal:

# once heapdump is exposed
curl -s localhost:8080/actuator/heapdump
Warning: Binary output can mess up your terminal. Use "--output -" to tell curl to output it to your terminal anyway, or consider "--output <FILE>" to save to a file.
curl -s -o /tmp/orders.hprof localhost:8080/actuator/heapdump
ls -l /tmp/orders.hprof

Security: these endpoints are dangerous

/env can show secrets, /heapdump contains every secret in memory, /loggers lets anyone turn on DEBUG logging of request bodies, /shutdown (off by default) stops the app. Standard practice:

The env endpoint masks values

Since Boot 3.0 the default is management.endpoint.env.show-values=never: every value is ******. You still get where each value came from, which is what you need to debug precedence:

# with env exposed (the config mission's setup does that)
curl -s localhost:8080/actuator/env/db.pool.max | jq .
{
  "property": { "source": "systemEnvironment", "value": "******" },
  "propertySources": [
    { "name": "commandLineArgs" },
    { "name": "systemProperties" },
    { "name": "systemEnvironment",
      "property": { "value": "******", "origin": "System Environment Property \"DB_POOL_MAX\"" } },
    { "name": "Config resource 'file [/etc/orders/app.conf]' via location 'optional:file:/etc/orders/app.conf[.properties]'",
      "property": { "value": "******", "origin": "URL [file:/etc/orders/app.conf] - 6:13" } },
    ...
  ]
}

show-values=always shows everything - including passwords, because Boot 3 no longer masks by key name. when-authorized shows them to users with a role. Treat always as a debugging switch you turn off again.

Actuator is also on a Kubernetes manifest

livenessProbe:   { httpGet: { path: /actuator/health/liveness,  port: 8081 } }
readinessProbe:  { httpGet: { path: /actuator/health/readiness, port: 8081 } }
startupProbe:    { httpGet: { path: /actuator/health/liveness,  port: 8081 } }
annotations:     { prometheus.io/path: /actuator/prometheus }      # tells a monitoring tool where the metrics page is

The next lesson is about getting those probe endpoints right.

Later (Ch 27): Prometheus is the monitoring system that reads (scrapes) that page every few seconds.

What you can now do

Why it helps

In a Kubernetes pod you often can't exec in with tools, and the image may have no JDK. Actuator is then the only way to get a thread dump, check the effective value of a property, or see why a health check fails. Knowing which endpoints exist and how exposure works turns "we need to redeploy with more logging" into a curl via port-forward.

It is also a security review item. /actuator/env with show-values=always, /heapdump and /loggers exposed through the public ingress are real findings in bank audits: a heap dump contains every secret in memory. When a team asks you to route their service, checking that management runs on a separate port that the ingress does not route is part of the job.

Commands in this lesson

curl

FAQ

Why does /actuator/prometheus return 404?

Because the endpoint exists but is not exposed. Since Spring Boot 2.5 only health is exposed over HTTP by default. You add others with management.endpoints.web.exposure.include=health,info,prometheus, and the metrics page in that text format also needs the micrometer-registry-prometheus library in the app (on its "classpath", the list of libraries Java loads). After a restart the log line Exposing N endpoints beneath base path '/actuator' confirms what is exposed. exclude wins over include.

Why does /actuator/env show ****** instead of values?

Since Spring Boot 3.0 the default is management.endpoint.env.show-values=never, so every value is masked. You still see which property sources define a property and its origin, the file and line or env var name, which is usually what you need to debug precedence. when-authorized shows values to users with a role; always shows everything including passwords, because Boot 3 no longer masks by key name.

Should Actuator run on a separate port?

Usually yes. management.server.port=8081 puts Actuator on its own port with its own small thread pool. That has two benefits: the ingress routes only the main port, so sensitive endpoints are not reachable from outside the cluster, and probes still get answered when all request threads are stuck. Kubernetes probes and metric collection then target the management port.

Is a heap dump from Actuator the same as one from jcmd?

Yes, the content is the same HPROF format, which you open in Eclipse MAT. The differences are practical: Actuator streams it over HTTP, so no JDK tools are needed in the image, and it arrives on your side instead of being written inside the container. Use curl -o file, never print it to your terminal. It pauses the JVM the same way and contains the same sensitive data.

Can I change log levels without a restart?

Yes, through /actuator/loggers if it is exposed. A GET shows the configured and effective level for each logger; a POST with {"configuredLevel":"DEBUG"} to /actuator/loggers/lab.orders changes it immediately. That is very useful during an incident and dangerous in the wrong hands, because DEBUG logging can write request bodies with personal data. It should be protected, and the level set back afterwards.

In an interview Mid

What is Spring Boot Actuator and why does a platform team care about it?

Actuator (spring-boot-starter-actuator) adds operational HTTP endpoints to a Spring Boot app: /actuator/health (with liveness and readiness groups), /metrics and /prometheus (numbers for monitoring), /env and /configprops (which config won), /threaddump, /heapdump, /loggers. It is how probes, monitoring and you at 3am talk to the app without a shell - the thread dump over HTTP works even when the image has no jcmd.

Only health is exposed by default; the rest is opt-in with management.endpoints.web.exposure.include=....

Why the platform cares about security: /env can show secrets (masked since Boot 3 unless show-values=always), /heapdump contains every secret in memory, /loggers lets anyone switch on DEBUG. Standard practice: management.server.port=8081, not routed through the Ingress; expose only what probes, monitoring and runbooks use.

Also asked: Actuator endpoints are reachable through the public ingress. What is the risk and how do you fix it? · How do you get a thread dump from a Spring Boot service whose image has no JDK? · Which Actuator endpoints are exposed by default, and how do you expose more?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.