← Back to the section

Kubernetes, load balancers and cloud platforms constantly poll your service: "are you alive?". The answer to this question determines whether the container gets traffic and whether the orchestrator restarts it. Incorrectly configured checks are one of the main causes of cascading failures: a small database problem turns into an outage of the entire service.

Why you need two different endpoints

Intuitively it seems that one /health is all you need. But Kubernetes has two completely different questions, and confusing the answers to them is dangerous.

Liveness probe asks: "is the process alive at all?". If not — K8s kills the container and starts a new one. Here you should check only the process itself: is the event loop responding, has Node.js hung. External dependencies (database, Redis, third-party APIs) must not be checked here.

Readiness probe asks: "is the service ready to accept requests?". If not — K8s simply removes the pod from the load balancer without restarting it. Here you do check the database connection and other critical external systems.

Why this matters: if the database lags for 30 seconds because of a maintenance operation, the correct behavior is to temporarily remove the pod from the load balancer (readiness DOWN) but not restart it (liveness UP). If liveness also depends on the database, K8s will start restarting all pods — and each new pod will hit the same lagging database. The whole service goes down because of a temporary problem.

Installing @nestjs/terminus

npm install @nestjs/terminus

Wiring it up in a module:

import { TerminusModule } from '@nestjs/terminus';

@Module({
  imports: [TerminusModule],
})
export class HealthModule {}

Basic structure: liveness and readiness

We create an indicator for the database and a controller with two different endpoints:

// src/management/pg-health.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { Pool } from 'pg';

@Injectable()
export class PgHealthIndicator extends HealthIndicator {
  constructor(private readonly pool: Pool) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await this.pool.query('SELECT 1');
      return this.getStatus(key, true);
    } catch (err) {
      const result = this.getStatus(key, false);
      throw new HealthCheckError('pg ping failed', result);
    }
  }
}
// src/management/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { PgHealthIndicator } from './pg-health.indicator';

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: PgHealthIndicator,
  ) {}

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([]);  // empty list — check only the process itself
  }

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.isHealthy('postgres'),
    ]);
  }
}
EndpointWhat it checksK8s reaction on DOWN
/health/liveProcess responds, event loop is aliveRestart the pod
/health/readyDB, critical dependenciesRemove from the load balancer, don't restart

Configuring the probe in Kubernetes

spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /health/live
          port: 9090
        initialDelaySeconds: 30
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 9090
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 2

Port 9090 here is a separate management port, isolated from the main business traffic on 3000. How to bring it up is described below.

A HealthIndicator with a cache for external systems

By default Kubernetes checks readiness every 5 seconds. With 10 replicas of the service this is 120 requests per minute from the health check alone. If each check makes a real request to a third-party payment gateway or an external API — you can exhaust the limits even before real users arrive.

The solution is to cache the check result for a few seconds:

import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { SberPaymentClient } from '../payment/sber-payment.client';

interface CachedResult {
  result: HealthIndicatorResult;
  expiresAt: number;
}

@Injectable()
export class SberPaymentHealthIndicator extends HealthIndicator {
  private cache: CachedResult | null = null;
  private readonly ttlMs = 10_000;

  constructor(private readonly client: SberPaymentClient) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const now = Date.now();
    if (this.cache && this.cache.expiresAt > now) {
      return this.cache.result;
    }

    const result = await this.check(key);
    this.cache = { result, expiresAt: now + this.ttlMs };
    return result;
  }

  private async check(key: string): Promise<HealthIndicatorResult> {
    try {
      await this.client.ping();
      return this.getStatus(key, true, { provider: 'sber' });
    } catch (err) {
      const result = this.getStatus(key, false, { provider: 'sber' });
      throw new HealthCheckError('SberPayment ping failed', result);
    }
  }
}

The SELECT 1 check against your own database doesn't need caching — it runs inside an already-open connection pool and is very cheap.

/info with version and git commit

During an incident the first question is always the same: "which version is deployed?". The /info endpoint answers it without any hassle.

NestJS has no built-in /actuator/info like Spring Boot, so you make a simple controller that reads environment variables set at build time:

import { Controller, Get } from '@nestjs/common';

@Controller('info')
export class InfoController {
  @Get()
  info() {
    return {
      service: {
        name: process.env.SERVICE_NAME ?? 'order-service',
        version: process.env.APP_VERSION ?? 'unknown',
      },
      git: {
        commitId: process.env.GIT_COMMIT_SHA ?? 'unknown',
        branch: process.env.GIT_BRANCH ?? 'unknown',
      },
      build: {
        time: process.env.BUILD_TIME ?? 'unknown',
      },
    };
  }
}

In the Dockerfile we pass the values via build args:

ARG GIT_COMMIT_SHA
ARG GIT_BRANCH
ARG BUILD_TIME
ARG APP_VERSION

ENV GIT_COMMIT_SHA=$GIT_COMMIT_SHA \
    GIT_BRANCH=$GIT_BRANCH \
    BUILD_TIME=$BUILD_TIME \
    APP_VERSION=$APP_VERSION

The result of a request to /info:

{
  "service": { "name": "order-service", "version": "2.4.1" },
  "git":     { "commitId": "5380f21abc3d", "branch": "main" },
  "build":   { "time": "2026-06-18T14:22:00Z" }
}

A separate management port

Health endpoints and metrics are better brought up on a separate port, isolated from business traffic. This lets you lock them down with a network policy so they're unreachable from outside the cluster, while Kubernetes can poll them from within.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);

  const mgmt = await NestFactory.create(ManagementModule);
  mgmt.setGlobalPrefix('');
  await mgmt.listen(9090);
}

ManagementModule contains only HealthController, InfoController and the metrics. Business routes are not reachable on port 9090.

Common mistakes

Business state in a health check. If there are more than a thousand pending orders — that's a business problem, not a technical one. A health DOWN in such a situation will remove the pod from the load balancer, which only makes the queue worse. Business metrics should be monitored through Prometheus and alerts, not through health checks.

Liveness depends on the database. The classic trap. The database lags for thirty seconds — K8s kills the pod, a new one comes up and hits the same lagging database — kills it again. Within a minute all replicas can go down because of a temporary delay on the PostgreSQL side.

The probe performs a business operation. "Let's create a test product and delete it" — that's a request to the database every five seconds, multiplied by the number of replicas. Plus garbage in the analytics. A probe must be lightweight: SELECT 1, a ping or a cached result.

In short

  • Liveness and readiness are two different endpoints with different semantics: liveness checks only the process itself, readiness — external dependencies.
  • Confusing them is dangerous: the database lags → liveness DOWN → K8s restarts everything → cascading failure.
  • For external APIs and payment gateways, a HealthIndicator needs a cache — without it the probe duplicates real traffic × the number of replicas.
  • SELECT 1 against your own database doesn't need caching — it's cheap.
  • /info with git-sha and version is a mandatory endpoint for quick diagnostics in production.
  • Move health endpoints and metrics to a separate port (9090), closed off from external traffic.
  • Metrics in NestJS — prom-client, business metrics and Prometheus.
  • Logging in NestJS — nestjs-pino, structured JSON logs.
  • Tracing in NestJS — OpenTelemetry, auto-instrumentation and manual spans.