← Back to the section

When an application fails in production, the first thing Kubernetes looks at is the health endpoints. If they are configured incorrectly, a single lagging database can take down the entire service, even when the process itself is perfectly fine. Let's break down how health checks work in Spring Boot and how to configure them properly.

What health checks are and why there are two

Kubernetes asks each pod two questions:

  1. Is the process alive? If not — restart it.
  2. Is it ready to accept traffic? If not — remove it from load balancing.

Spring Boot Actuator answers these questions through two separate endpoints: /actuator/health/liveness and /actuator/health/readiness.

Enable them in application.yml:

management:
  endpoint:
    health:
      probes:
        enabled: true
      show-details: always
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

Now you have two endpoints with different semantics:

EndpointWhat UP meansWhat K8s does on DOWN
/actuator/health/livenessthe process is alive, the JVM respondsrestarts the pod
/actuator/health/readinessthe service is ready: DB connected, warmup completeremoves the pod from load balancing

Why liveness and readiness must not be mixed

Imagine: the database lags for 30 seconds due to maintenance. What should happen?

  • Readiness should become DOWN — traffic moves to other replicas that are working normally.
  • Liveness should stay UP — restarting the pod won't fix the database, it will only add chaos.

If liveness depends on the database, the following happens: the database lags → liveness DOWN → Kubernetes kills the pod → a new pod starts, the same database is still lagging → DOWN again → killed again. Within a minute all replicas go down and the service is unavailable.

Liveness checks only the process itself: the JVM is alive, threads are not frozen, disk is accessible. External dependencies belong to readiness only.

Here is an example of dangerous code that must not be used for liveness:

// DON'T — liveness will go down together with the database
@Component
public class CustomLivenessIndicator implements HealthIndicator {
    public Health health() {
        try {
            jdbcTemplate.execute("SELECT 1");
            return Health.up().build();
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

Kubernetes manifests for probes

spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /actuator/health/liveness
          port: 8081
        initialDelaySeconds: 30
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /actuator/health/readiness
          port: 8081
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 2

Port 8081 is a separate management port (management.server.port). This lets you restrict /actuator/* with a network policy so that it is only reachable inside the cluster.

Custom HealthIndicator for external systems

Spring Boot automatically checks the database and Redis when they are connected. For any other external system you need to write your own HealthIndicator.

The problem is that Kubernetes polls readiness every 5 seconds, and with 10 replicas that's 2 requests per second from health checks alone. If each of them actually calls the external provider, you get continuous load that can exhaust the API limits.

The solution is a TTL cache: check the provider no more often than once every 10-30 seconds, and serve the stored result to all other requests.

@Component
@RequiredArgsConstructor
public class PaymentProviderHealthIndicator implements HealthIndicator {

    private final PaymentProviderClient client;
    private final AtomicReference<CachedHealth> cache = new AtomicReference<>();
    private static final Duration TTL = Duration.ofSeconds(10);

    @Override
    public Health health() {
        var cached = cache.get();
        if (cached != null && cached.expiresAt().isAfter(Instant.now())) {
            return cached.health();
        }

        var health = checkProvider();
        cache.set(new CachedHealth(health, Instant.now().plus(TTL)));
        return health;
    }

    private Health checkProvider() {
        try {
            client.ping();
            return Health.up().withDetail("provider", "payment").build();
        } catch (Exception e) {
            return Health.down(e).withDetail("provider", "payment").build();
        }
    }

    private record CachedHealth(Health health, Instant expiresAt) {}
}

ping() should be a lightweight request — GET /health or OPTIONS /. There's no need to create test data or perform real business operations: every 5 seconds across 10 replicas that would already be 120 real business operations per minute from health checks alone.

/actuator/info: which version is in production right now

A classic situation during an incident: "which version is deployed right now?" Without special configuration you have to dig for the answer in the CI/CD logs. It's far more convenient to ask the service itself.

Add the git-commit-id-plugin to Gradle and extend application.yml:

management:
  info:
    git:
      mode: full
    build:
      enabled: true
info:
  service:
    name: ${spring.application.name}

After that /actuator/info responds:

{
  "git": {
    "commit": {
      "id": "5380f21abc...",
      "time": "2026-05-25T22:24:00Z"
    },
    "branch": "main"
  },
  "build": {
    "version": "1.4.2",
    "time": "2026-05-25T22:25:30Z",
    "artifact": "order-service"
  },
  "service": {
    "name": "order-service"
  }
}

Common mistakes

Business metrics instead of technical state. "If more than 1000 unprocessed orders pile up — report DOWN" is not about the health of the process. A health DOWN causes K8s to pull the replica out of load balancing, the remaining replicas receive even more orders, and the backlog grows faster. A spiral that ends in complete unavailability of the service.

Track business metrics through Prometheus + alerting, separately from health checks.

HealthIndicator without a cache. If you remove the TTL cache from the example above, every probe call will actually reach out to the provider. With many replicas and frequent checks this creates load that interferes with real requests.

Liveness depending on external systems. This is the most dangerous mistake: it causes a cascading restart of all pods on any dependency problem.

In short

  • Health checks come in two kinds: liveness (is the process alive) and readiness (is it ready to accept traffic).
  • Liveness depends only on the process itself — the JVM, threads, disk. No external systems.
  • Readiness checks the availability of dependencies: the database, caches, external APIs.
  • For each external system write your own HealthIndicator with a TTL cache, so as not to overload the provider with frequent checks.
  • The probe method must be lightweight: GET /health, OPTIONS /. Not a business operation.
  • Health is the technical state of the process and its dependencies. Business metrics belong in Prometheus.
  • /actuator/info with git-commit-id-plugin lets you instantly answer the question "what's in production right now".
  • Metrics and Micrometer — how to monitor business metrics through Prometheus.
  • Request tracing — how to link logs and requests via a trace ID.
  • SLO and alerts — how to set availability targets and configure alerts.