Skip to content

Health Checks and Readiness Probes

Aman Kumar Singh6 min read
Part 27 of 40From the NestJS Production Guide series
Health Checks and Readiness Probes — article by Aman Kumar Singh

In Logging with a Custom Logger, we made sure that when something goes wrong in production, you can actually see it happen. Health checks are the other half of that story. They let your infrastructure decide, automatically, whether a running instance should receive traffic at all. This is part of the NestJS Production Guide series, and it sits right at the boundary between application code and the orchestrator running it.

Most teams bolt on a /health endpoint that returns { status: 'ok' } and call it done. That works fine until the first real deploy. Then a new instance starts accepting requests before its database connection pool is warm, or a container keeps failing readiness checks forever because the health endpoint itself depends on a service that's still starting up. Getting this right early saves you from debugging flaky deploys later, and it's cheap to build correctly the first time.

I'll cover the difference between liveness and readiness, how to implement both with @nestjs/terminus, and the failure modes that actually show up once you wire health checks into Docker, ECS, or Kubernetes.

Why liveness and readiness are different questions

A liveness check answers one question: is this process still functioning, or should the orchestrator kill and restart it? A readiness check answers a different one: is this instance currently able to serve traffic correctly?

Conflating the two is the most common mistake. If your liveness probe checks the database connection and the database has a brief network blip, Kubernetes or ECS will restart every instance simultaneously, even though the process itself was perfectly healthy. A transient database issue turns into a full outage. Liveness should only fail when the process is genuinely stuck: deadlocked, out of memory, or unresponsive at the event loop level. Readiness can and should fail when a downstream dependency is unavailable, because in that state you don't want the load balancer sending users to this instance.

Getting this distinction wrong is subtle because both endpoints tend to look identical in early-stage projects. They only diverge once you have real dependencies and a real deploy pipeline putting instances in and out of rotation.

Implementing health checks with @nestjs/terminus

NestJS has an official module for this, @nestjs/terminus, and it's worth using instead of a hand-rolled controller. It gives you a consistent response shape and a set of indicators for common dependencies.

npm install @nestjs/terminus @nestjs/axios

A health module with separate liveness and readiness endpoints looks like this:

// health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { HealthController } from './health.controller';
import { RedisHealthIndicator } from './redis-health.indicator';

@Module({
  imports: [TerminusModule, HttpModule],
  controllers: [HealthController],
  providers: [RedisHealthIndicator],
})
export class HealthModule {}
// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  TypeOrmHealthIndicator,
  MemoryHealthIndicator,
} from '@nestjs/terminus';
import { RedisHealthIndicator } from './redis-health.indicator';

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

  // Liveness: is the process itself still functioning?
  // No downstream dependencies here on purpose.
  @Get('live')
  @HealthCheck()
  checkLiveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
    ]);
  }

  // Readiness: can this instance actually serve requests right now?
  @Get('ready')
  @HealthCheck()
  checkReadiness() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1500 }),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

The custom Redis indicator is straightforward. Terminus doesn't ship one out of the box, so you write it against whatever client you're already using:

// health/redis-health.indicator.ts
import { Injectable } from '@nestjs/common';
import {
  HealthIndicator,
  HealthIndicatorResult,
  HealthCheckError,
} from '@nestjs/terminus';
import Redis from 'ioredis';
import { InjectRedis } from '@liaoliaots/nestjs-redis';

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(@InjectRedis() private readonly redis: Redis) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      const reply = await this.redis.ping();
      const isHealthy = reply === 'PONG';
      const result = this.getStatus(key, isHealthy);
      if (!isHealthy) {
        throw new HealthCheckError('Redis check failed', result);
      }
      return result;
    } catch (error) {
      throw new HealthCheckError(
        'Redis check failed',
        this.getStatus(key, false, { message: error.message }),
      );
    }
  }
}

Notice that checkLiveness only looks at memory, something intrinsic to the process. checkReadiness checks the database and cache, the things that determine whether this instance can actually handle a real request. That split is the whole point.

Wiring probes into Docker, ECS, and Kubernetes

Once the endpoints exist, each environment uses them a little differently.

For local Docker Compose, a HEALTHCHECK gives you visibility during docker ps and lets depends_on: condition: service_healthy work correctly:

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health/ready"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 20s

For an ECS service behind an Application Load Balancer, the target group's health check path should point at /health/ready, not /health/live. The ALB is deciding whether to route traffic, which is a readiness question. Set healthy_threshold and unhealthy_threshold with enough margin that a single slow request during deploy doesn't yank a healthy instance out of rotation.

For Kubernetes, the split is explicit in the pod spec:

livenessProbe:
  httpGet:
    path: /health/live
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 15
readinessProbe:
  httpGet:
    path: /health/ready
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 10

The initialDelaySeconds values matter more than they look. If your app takes a few seconds to establish its database pool on boot, a readiness probe that starts checking immediately will flap the pod between ready and not-ready during startup, which can confuse rolling deployments.

Production pitfalls worth knowing before they bite you

The first pitfall is making the health endpoint itself expensive. If /health/ready runs a full query against every table your app touches, you've turned a cheap, frequent check into real load on your database, multiplied by every instance polling every few seconds. A single lightweight ping per dependency is enough. It confirms the dependency is reachable. It doesn't need to validate the data underneath it.

The second is forgetting that health checks bypass your normal auth and rate-limiting middleware in most setups, since the load balancer or orchestrator hits them directly and frequently. Exclude the health routes from anything that would throttle or log them noisily. At the same time, keep the response generic. A status field is fine. Stack traces and connection details are not.

The third, and the one that causes the worst incidents, is cascading failure from an overly strict readiness check. Say your readiness probe fails whenever Redis is briefly unreachable, and every instance in your fleet loses connectivity to Redis at the same time. That's a common failure mode during a Redis failover or maintenance window. Every instance goes unready at once. Now you have zero healthy instances, and the outage was caused by a monitoring signal rather than the actual dependency loss. Think carefully about whether a dependency should really gate readiness, or whether your application can degrade gracefully instead, serving cached or reduced functionality while Redis recovers.

Finally, don't skip the start_period or initialDelaySeconds equivalents. A container that needs a few seconds to open its connection pool but gets killed by an impatient liveness check before it finishes booting will loop forever between starting and being killed. The error in your logs will just say the process didn't respond in time, which sends you looking in the wrong place first.

Key takeaways

  • Liveness answers "is the process stuck," readiness answers "can this instance serve traffic right now." Keep dependency checks out of liveness.
  • Use `@nestjs/terminus` for a consistent health check shape instead of hand-rolling one; write custom indicators for anything it doesn't cover out of the box, like Redis.
  • Point load balancer and ALB target group health checks at the readiness endpoint, not liveness.
  • Keep health check logic cheap: a ping, not a full query. These endpoints get hit far more often than any other route in your app.
  • Give startup enough grace period (`start_period`, `initialDelaySeconds`) before probes start failing instances that simply haven't finished booting.
  • Think carefully about which dependencies should gate readiness. A shared dependency failing everywhere at once can turn a monitoring signal into the outage itself.

Frequently asked questions

What is the difference between a liveness probe and a readiness probe?

A liveness probe tells the orchestrator whether the process is still functioning and should be restarted if it isn't. A readiness probe tells the load balancer whether this specific instance should currently receive traffic. A process can be alive but not ready, for example while its database connection pool is still initializing.

Should my health check endpoint query the database?

Yes, but only with a lightweight ping or connection check, not a real query against application tables. The goal is to confirm connectivity, not to validate business logic. A heavy health check adds load proportional to your instance count and probe frequency.

Why does my Kubernetes pod keep restarting even though the app looks fine in logs?

This usually means the liveness probe is starting before the app finishes booting, or `initialDelaySeconds` is too short relative to actual startup time. Check whether the probe is hitting the app before the port is even listening, and increase the delay or add a `startupProbe` if your framework's boot time is inconsistent.

Can I use the same endpoint for both liveness and readiness?

You can, and many small projects do this early on, but it's worth separating them once you have real downstream dependencies. A shared endpoint that checks dependencies will cause liveness failures (and restarts) during transient outages that should only affect readiness.

How do I avoid health checks becoming a source of outages themselves?

Be deliberate about which dependencies gate readiness. If a dependency failing for every instance at once (like a shared Redis cluster) would take your entire readiness endpoint down, consider whether the app can degrade gracefully instead of reporting unready everywhere simultaneously.

Do health check endpoints need authentication?

No, they're typically called by infrastructure, not end users, and requiring auth would complicate the load balancer or orchestrator configuration for little benefit. Just keep the response minimal, no internal errors, connection strings, or stack traces, since the endpoint is often reachable without credentials.

Related articles

Integration and E2E Testing — Aman Kumar Singh
WebSockets and Gateways — Aman Kumar Singh
Caching with Redis in NestJS — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.