What is NestJS Terminus?

@nestjs/terminus is the official NestJS module for health checks. A service is only as healthy as its dependencies: the database, the cache, and the disk it writes to. An orchestrator or load balancer cannot read your logs, but it can read an HTTP status code. Terminus converts the state of those dependencies into a status code that the platform can act on. It also returns a JSON body that names each dependency and whether it passed.

Who should use it?

Terminus is for backend and platform engineers who run NestJS services behind something that makes automated decisions from an HTTP endpoint. Typical examples are Kubernetes liveness, readiness and startup probes, or a load balancer's target health check.

It is most useful in these cases:

  • The service depends on one or more stateful backends, such as PostgreSQL, MySQL or Redis, or writes to a local volume.
  • The service runs as multiple replicas, so routing traffic away from an unhealthy pod is useful.
  • On-call engineers need to see which dependency failed without searching through logs.

It adds less value for a stateless worker or sidecar with no external dependencies. A minimal liveness route, or a TCP probe, is usually enough there. If nothing acts on the endpoint's status code, metrics and alerting are a better fit than a health route.

How does a Terminus health check work?

Setup takes three pieces. First, add TerminusModule to the imports of a module. Second, create a dedicated HealthController so health routes stay apart from business routes and their guards or interceptors. Third, inject HealthCheckService into the controller and decorate the handler with @HealthCheck(), which also marks the route correctly for Swagger if you use it.

The core call is HealthCheckService.check(). It takes an array of functions, and each function returns a promise that resolves to an indicator result keyed by a name you choose, such as database or redis. Terminus runs them and aggregates the outcomes into one object with four fields:

  • status: overall ok or error
  • info: indicators that passed
  • error: indicators that failed
  • details: every indicator, passing or not

If every indicator resolves, the endpoint returns 200. If any indicator fails, Terminus throws a ServiceUnavailableException, and Nest turns that into a 503. The failing key appears under error, and the body still lists the healthy ones.

The body is for humans debugging an incident. Kubernetes probes and most load balancers decide only on the status code. That status code is what matters to backend and platform engineers running Nest services behind those systems.

// health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';

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

// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  MemoryHealthIndicator,
} from '@nestjs/terminus';

// Separate controller keeps probes away from business guards and interceptors
@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly memory: MemoryHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    // Each entry is a function returning a promise; the first argument is the key
    // that appears in info/error/details. Any failure -> ServiceUnavailableException -> 503.
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
      () => this.memory.checkRSS('memory_rss', 500 * 1024 * 1024),
    ]);
  }
}

A minimal Terminus setup: TerminusModule imported into a module, a dedicated HealthController, and HealthCheckService.check() under @HealthCheck() running an array of indicator functions whose keys show up in the aggregated response, returning 200 when all pass and 503 when any fails.

Why one catch-all /health route fails: mapping checks to orchestrator decisions

A single catch-all route asks one status code to answer three different questions. Kubernetes acts on each answer differently, so each check should map to one decision: restart the container, stop routing traffic to it, or wait for it to finish booting.

            kubelet
   ┌───────────┼───────────────┐
   │ liveness  │ readiness     │ startup
   ▼           ▼               ▼
/health/live  /health/ready ◄──┘ (until first success)
 (in-process)      │
                   ├── PostgreSQL / MySQL
                   ├── Redis
                   └── local disk

/health/live only proves that the Nest process can accept a request and return a response. It has no external dependencies. /health/ready checks the database, Redis and disk, because those determine whether this pod can do useful work right now.

Probe Endpoint Checks Action on failure
livenessProbe /health/live Event loop responds Container restarted
readinessProbe /health/ready DB, Redis, disk Pod removed from Service endpoints, not restarted
startupProbe /health/ready or /health/live Boot complete Liveness and readiness held off; container restarted only after the failure threshold

The startupProbe exists for slow boots, such as running migrations or warming a cache. Without it, you would have to raise the liveness delay for every pod for the whole of its life.

Putting database or Redis checks in liveness causes harm during an outage. When the database goes down, every pod fails liveness together, and the kubelet restarts all of them. A restart cannot fix a remote database. Each new container also rebuilds its connection pool and retries on boot, so a database that is already struggling receives a burst of reconnect load. After repeated failures, the restart backoff can keep pods down even after the database has recovered. If the same failure goes only to readiness, the pods simply drop out of rotation and come back once their dependencies respond.

                        ┌───────────┐
                        │  kubelet  │
                        └─────┬─────┘
        ┌─────────────────────┼─────────────────────┐
        │ livenessProbe       │ startupProbe        │ readinessProbe
        │                     │ (until first pass)  │
        │                     └────────────────────►┤
        ▼                                           ▼
┌────────────────────┐                  ┌──────────────────────┐
│ GET /health/live   │                  │ GET /health/ready    │
│ in-process only    │                  │ DB + Redis + disk    │
│ no external deps   │                  └───────────┬──────────┘
└─────────┬──────────┘                              │
          │ fail                      ┌─────────────┼───────────┐
          ▼                           ▼             ▼           ▼
 ┌──────────────────┐            ┌──────────┐  ┌────────┐  ┌──────────┐
 │ restart container│            │PostgreSQL│  │ Redis  │  │local disk│
 └──────────────────┘            │ / MySQL  │  └────────┘  └──────────┘
                                 └──────────┘

 readiness fail ──► pod removed from Service endpoints (not restarted)
 startup fail past threshold ──► container restarted

The kubelet sends liveness probes to an in-process /health/live endpoint and readiness and startup probes to /health/ready, so only /health/ready depends on the database, Redis and disk, and each failure leads to a different action.

Implementing the database, Redis and disk indicators

The readiness endpoint is where the dependency checks belong, so each indicator below runs only there.

Database

Use the ORM-specific indicator that ships with Terminus, such as TypeOrmHealthIndicator.pingCheck('database', { timeout: 1500 }). The key becomes the field name in the response, and the timeout stops a hung connection from stalling the whole probe. A successful ping proves that the app can reach the server and that the server answers. It does not prove that migrations ran, that a replica is current, or that the connection pool has free slots. A pool exhausted by slow queries can still pass a ping, because the check may get a connection before the queued requests do.

Redis

Write a custom indicator that sends PING through the Redis client the app already uses. A separate client would only show that some connection works. Wrap the call in its own timeout with Promise.race, because client libraries often queue commands while reconnecting and may never reject. Report the result as up or down under a key such as redis.

The custom-indicator API has changed across Terminus releases. Older code extends a HealthIndicator base class and calls getStatus. Newer code injects an indicator service instead. Follow the pattern documented for your installed version, and keep the PING-and-timeout logic in a plain function so it survives the next API change.

Disk

DiskHealthIndicator.checkStorage accepts a path with either thresholdPercent or an absolute threshold. Point it at the volume the app writes to, such as logs, uploads or temp files, rather than /. In a container, the root filesystem is often not where writes fail.

Terminus also provides heap and RSS memory indicators. They usually belong in alerting, not probes. Memory climbs gradually, and a probe failure turns that slow trend into an abrupt restart or a pod dropping out of rotation.

import { Controller, Get, Inject, Injectable } from '@nestjs/common';
import { DiskHealthIndicator, HealthCheck, HealthCheckService, HealthIndicatorService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import type Redis from 'ioredis';

// Plain function: PING with its own timeout, independent of the Terminus API.
// Promise.race guards against clients that queue commands while reconnecting.
export async function pingRedis(client: Redis, timeoutMs: number): Promise<void> {
  let timer: NodeJS.Timeout | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`PING timed out after ${timeoutMs}ms`)), timeoutMs);
  });
  try {
    const reply = await Promise.race([client.ping(), timeout]);
    if (reply !== 'PONG') throw new Error(`unexpected reply: ${reply}`);
  } finally {
    clearTimeout(timer);
  }
}

// Thin adapter using the injected-service style (newer Terminus); older releases extend HealthIndicator instead.
@Injectable()
export class RedisHealthIndicator {
  // 'REDIS_CLIENT' is the app's existing client, not a separate connection.
  constructor(private readonly indicators: HealthIndicatorService, @Inject('REDIS_CLIENT') private readonly redis: Redis) {}

  async isHealthy(key: string, timeoutMs = 1000) {
    const indicator = this.indicators.check(key);
    try {
      await pingRedis(this.redis, timeoutMs);
      return indicator.up();
    } catch (err) {
      return indicator.down({ message: (err as Error).message });
    }
  }
}

@Controller('health')
export class HealthController {
  constructor(private health: HealthCheckService, private db: TypeOrmHealthIndicator, private disk: DiskHealthIndicator, private redis: RedisHealthIndicator) {}

  @Get('ready') // dependency checks live only on readiness, never liveness
  @HealthCheck()
  ready() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1500 }), // connectivity only, not schema or pool health
      () => this.redis.isHealthy('redis'),
      () => this.disk.checkStorage('disk', { path: '/app/data', thresholdPercent: 0.9 }), // the volume the app writes to, not /
    ]);
  }
}

A readiness endpoint that combines the TypeORM ping, a custom Redis PING indicator whose timeout logic lives in a plain function, and a disk check pointed at the app's write volume, wired here with the injected HealthIndicatorService style used by newer Terminus releases.

Wiring probes: timeouts, periods, failure thresholds and graceful shutdown

The Deployment snippet below points each probe at its own route. startupProbe and livenessProbe hit the shallow liveness endpoint. readinessProbe hits the endpoint that checks the database, Redis and disk. While the startup probe is still failing, Kubernetes does not run the other two probes. This lets slow boots, such as migrations or cache warm-up, finish without the liveness probe restarting the container.

Set each indicator's own timeout (for example, the timeout option on a ping check) lower than the probe's timeoutSeconds. If the indicator times out first, Terminus returns a 503 that names the failing key, such as redis: down. If the probe times out first, the kubelet records only a timeout, and you lose the information about which dependency failed.

Choose periodSeconds and failureThreshold as a pair, because together they set how long a failure goes unnoticed.

Illustrative estimate (assumed numbers, not a benchmark):

time to leave rotation ≈ periodSeconds × failureThreshold (+ up to timeoutSeconds on the final attempt)

With a 10s period and a threshold of 3: 10 × 3 = 30s. During that window, a pod with a dead dependency still receives traffic. A lower threshold reacts faster but is more likely to pull pods out of rotation during short network hiccups.

For shutdown, call app.enableShutdownHooks() so Nest runs its lifecycle hooks on SIGTERM instead of exiting immediately. When a pod starts terminating, Kubernetes removes it from Service endpoints, but that change takes time to reach proxies and load balancers. Terminus can add a graceful shutdown delay that keeps the server handling requests during this window. The option name has changed between releases, so confirm it in the docs for your version. Keep terminationGracePeriodSeconds larger than that delay plus the time your app needs to close its connections.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 3
  selector:
    matchLabels: { app: orders-api }
  template:
    metadata:
      labels: { app: orders-api }
    spec:
      # Must exceed Terminus shutdown delay + time to finish in-flight requests
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: registry.example.com/orders-api:1.0.0
          ports:
            - containerPort: 3000
          # Shallow check; allows up to 30 x 5s = 150s for migrations/warm-up.
          # Liveness and readiness do not run until this succeeds.
          startupProbe:
            httpGet: { path: /health/live, port: 3000 }
            periodSeconds: 5
            failureThreshold: 30
          # Shallow check only: a dead DB should not restart the container
          livenessProbe:
            httpGet: { path: /health/live, port: 3000 }
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          # Deep check (DB, Redis, disk). Indicator timeouts (e.g. 1500ms)
          # stay below timeoutSeconds so the 503 body names the failing key.
          # Illustrative estimate: 10s x 3 = ~30s before leaving rotation.
          readinessProbe:
            httpGet: { path: /health/ready, port: 3000 }
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3

A Deployment that sends startup and liveness probes to the shallow /health/live route and the readiness probe to the dependency-checking /health/ready route. Probe timeouts are set above the indicator timeouts, and the termination grace period leaves time for a graceful shutdown delay.

Testing the health endpoints

Health logic is easy to get wrong in ways that only show up under failure, so test the failure paths directly. A misplaced check, such as Redis wired into liveness, looks fine on a healthy laptop and only causes trouble when Redis blips and every pod restarts at once. A few tests catch that in minutes instead of during an incident.

Unit tests with mocked indicators

Build a testing module that imports TerminusModule, registers your health controller, and replaces each indicator with a stub. Do the same for your custom Redis indicator. Have each stub's check method resolve to a result keyed by its name, such as { redis: { status: 'up' } }. To simulate a failure, make the stub reject with a HealthCheckError that carries { redis: { status: 'down' } }.

Spin up the app with createNestApplication() and call it through supertest so you exercise the real HTTP status mapping. Write four assertions:

  • With all stubs up, /health/ready returns 200.
  • With the Redis stub down, /health/ready returns 503 and the body's error object contains a redis key while info still lists the database.
  • With Redis down, /health/live still returns 200.
  • Liveness never calls the Redis stub at all.

The third and fourth assertions protect the liveness/readiness split. They will fail the moment someone adds a dependency check to the wrong route.

Manual check

Run the app locally against a containerized Redis, then stop the container. Call curl -i localhost:3000/health/ready and confirm it returns 503 with redis under error. Then call /health/live and confirm it still returns 200. Restart Redis and confirm readiness recovers without restarting the app.

When to avoid deep checks and other trade-offs

That test confirms the wiring works. It does not show that every check belongs on the readiness path.

Shared dependencies fail every replica at once

If all replicas check the same database, an outage marks the whole fleet unready together and the Service has no endpoints left. Clients then get connection errors instead of a clear 503 from your application. Decide per dependency. Fail closed when serving without it is unsafe. Fail open, staying ready and returning degraded responses such as cached reads, when partial service is better than none.

Probe traffic scales with replicas

Illustrative estimate (assumed numbers, not a benchmark): assume 40 replicas probing readiness every 5 seconds, each running a database and a Redis check. That sends 40 ÷ 5 = 8 pings per second to each dependency. The load is small but constant. Shorter periods, more replicas or extra probes multiply it.

Shallow versus deep checks

A successful ping can miss slow queries, an exhausted connection pool or Redis near its memory limit. Deeper checks catch more, but they take longer and fail for unrelated reasons such as lock contention. That leads to false unreadiness or restarts. Track these signals in metrics and alerts instead.

Disk checks and exposed details

Disk usage that hovers near a threshold makes readiness flap. On ephemeral filesystems, alerting combined with the orchestrator's eviction policy usually handles pressure better than a probe. Detailed unauthenticated responses can leak hostnames or driver errors, so return only the status publicly. Code copied from older Terminus examples may use deprecated or removed APIs, so verify it against the current documentation.

Alternatives and impact on cost and engineering time

Terminus is one of four common approaches, and the right choice depends on what each probe needs to tell the orchestrator.

Approach Setup effort Signal depth Maintenance on upgrades Liveness vs readiness
Terminus Low to moderate Named per-dependency results Follow Terminus and ORM/driver API changes Good for both
Hand-written controller Low at first, grows over time Whatever you build You own timeouts, aggregation and response format Both, if done carefully
Kubernetes TCP or exec probe Very low Port open or command exit code only Minimal Liveness only, in practice
Metrics-based alerting Moderate, needs a metrics stack Rich trends Dashboards and alert rules drift Neither. It alerts people but does not gate traffic

Terminus costs an initial setup plus periodic upgrade work, because indicator APIs have changed between major versions. Deep checks also put load on shared infrastructure that grows with fleet size.

Illustrative estimate (assumed numbers, not a benchmark): DB queries per second = pods × DB-checking probes per pod ÷ period. With 60 pods, one readiness probe each, and a 10-second period: 60 × 1 ÷ 10 = 6 queries/s against the shared database. That stays constant, even when traffic is idle.

The time savings come from three places:

  • The orchestrator restarts hung processes, so nobody has to do it by hand.
  • Named keys such as database or redis show which dependency failed without anyone digging through logs.
  • Readiness gating makes rollouts and drains safer.

These gains are largest for services that depend on several stateful backends and run many replicas. A stateless worker or a sidecar with no external dependencies only needs a minimal liveness check.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs terminus health check, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.