NestJS Health Checks with Terminus for Kubernetes & Docker

Photo by kewl via Openverse (CC BY 2.0)
A liveness probe answers whether the process itself is still running and responsive, and should only check in-process signals like heap memory — never external dependencies. A readiness probe answers whether the instance can currently serve traffic, so it should check things like database connectivity. Conflating the two means a database outage can trigger Kubernetes to restart every pod, turning a recoverable blip into a full outage.
Run npm install --save @nestjs/terminus, then import TerminusModule into a dedicated HealthModule. From there you can inject HealthCheckService plus any built-in health indicator (TypeOrmHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator, and more) into a HealthController and call health.check() with an array of indicator functions.
Yes — Terminus ships MongooseHealthIndicator for Mongoose-based MongoDB connections, which works the same way as TypeOrmHealthIndicator's pingCheck: it runs a lightweight check against the active connection and reports it as up or down inside the aggregated health response.
You expose two HTTP endpoints, such as /health/live and /health/ready, and point Kubernetes' livenessProbe.httpGet at the liveness endpoint and readinessProbe.httpGet at the readiness endpoint. livenessProbe controls whether Kubernetes restarts the pod, while readinessProbe controls whether the pod receives traffic from a Service — they're configured independently with their own timing thresholds.
Readiness checks should verify anything required to actually serve a request successfully — database pings, disk space, downstream service reachability. Liveness checks should stay limited to signals about the process itself, like heap usage or event loop health, so an external outage never causes Kubernetes to restart healthy pods.

Photo by kewl via Openverse (CC BY 2.0)
When a NestJS service runs behind Kubernetes or Docker, the orchestrator needs a reliable way to ask two very different questions: is the process still alive, and can it actually serve traffic right now. Answering both correctly is what makes rolling deploys, auto-healing, and load balancing work without dropping requests. The official @nestjs/terminus package gives you a small, well-tested toolkit for exactly this, and this post walks through building a real health module from scratch, including the distinction most teams get wrong: conflating liveness with readiness into one endpoint.
Terminus ships as its own package rather than being bundled into NestJS core, so you install it separately and register a TerminusModule inside a dedicated HealthModule. This keeps health check concerns isolated from your business logic modules and makes it trivial to exclude the health module from things like global guards or interceptors that shouldn't run on every liveness ping. Once TerminusModule is imported, you get access to HealthCheckService and a set of built-in health indicator providers you can inject into any controller.
npm install --save @nestjs/terminus
# 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 {}The core building block is HealthCheckService.check(), which accepts an array of indicator functions and aggregates their results into a single response with an overall status of ok or error, plus a per-indicator breakdown. Each indicator function is provided by an injectable health indicator class — Terminus ships several out of the box for the most common infrastructure dependencies a backend service touches.
If your service uses TypeORM, TypeOrmHealthIndicator.pingCheck() runs a lightweight query against the configured connection and reports it as up or down. Teams on Mongoose use the equivalent MongooseHealthIndicator instead — same pattern, same pingCheck-style API, just targeting a different ODM connection. This is the single most important check for a readiness probe, because a service that can't reach its database has no business receiving traffic even if the Node.js process itself is perfectly healthy.
MemoryHealthIndicator.checkHeap() and checkRSS() guard against a slow memory leak silently degrading a pod before it OOM-kills, while DiskHealthIndicator.checkStorage() reports unhealthy once a mounted volume crosses a configured usage percentage. These are process-level signals rather than dependency signals, which is exactly why they belong on the liveness path — they answer whether the container itself is in a viable state, independent of anything external.
// health.controller.ts
import { Controller, Get } from "@nestjs/common"
import {
HealthCheckService,
HealthCheck,
TypeOrmHealthIndicator,
MemoryHealthIndicator,
DiskHealthIndicator,
} from "@nestjs/terminus"
@Controller("health")
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
) {}
// Liveness: is the Node.js process itself still running and
// responsive? No dependency calls — Kubernetes uses this to decide
// whether to KILL and restart the pod.
@Get("live")
@HealthCheck()
checkLiveness() {
return this.health.check([
() => this.memory.checkHeap("memory_heap", 300 * 1024 * 1024),
])
}
// Readiness: can this instance actually serve traffic right now?
// Checks the database connection and disk headroom — Kubernetes
// uses this to decide whether to ROUTE traffic to the pod.
@Get("ready")
@HealthCheck()
checkReadiness() {
return this.health.check([
() => this.db.pingCheck("database"),
() =>
this.disk.checkStorage("storage", {
path: "/",
thresholdPercent: 0.9,
}),
])
}
}Keep the liveness check to in-process signals only — heap size, event loop responsiveness, maybe a trivial disk check. The moment a liveness probe calls out to a database or a downstream API, an unrelated outage turns into a self-inflicted restart storm across your entire deployment.
A liveness probe answers one question only: is this process stuck or crashed and does it need to be killed and restarted. It should never depend on external systems — if your database has a five-minute outage, Kubernetes should not start restarting every pod in the deployment because that just adds a thundering-herd restart storm on top of an already-bad situation. A readiness probe answers a different question: can this specific instance serve traffic right now. It's fine — expected, even — for a readiness probe to fail while liveness stays green: that's exactly the signal Kubernetes needs to pull a pod out of the load balancer without killing it, then put it back once the dependency recovers.
A common mistake is putting the database ping on the liveness endpoint instead of readiness. If the database restarts or has a brief network blip, Kubernetes will interpret that as every pod being dead and restart the whole deployment — turning a recoverable dependency hiccup into a full outage with cold-start latency on top.
Once /health/live and /health/ready exist as real HTTP endpoints, wiring them into infrastructure is mostly config, not code. Docker's HEALTHCHECK instruction polls a single endpoint on an interval and marks the container unhealthy after a run of consecutive failures — most teams point this at the readiness endpoint so `docker ps` reflects whether the container can actually do useful work. Kubernetes splits the concept natively: livenessProbe controls restarts, readinessProbe controls whether the pod receives traffic from a Service, and they're configured independently with their own initialDelaySeconds, periodSeconds, and failureThreshold tuning.
# Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health/ready || exit 1
# kubernetes deployment.yaml
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3A service that takes 30 seconds to warm up a connection pool or load a large in-memory cache creates a real tuning conflict: a liveness probe generous enough to tolerate that startup window is also too generous for catching a genuine hang later on. Kubernetes' startupProbe solves this cleanly by disabling liveness and readiness checks entirely until the startup probe itself succeeds once, after which the normal probes take over with their tighter timings. It's worth reaching for whenever a NestJS app has meaningfully slow bootstrap logic — module initialization, cache warming, or an initial migration check.
If you're running behind an API gateway or service mesh that also does its own health polling, make sure it's pointed at /health/ready, not /health/live — otherwise a database blip gets misreported as the whole node being down.
Before trusting these endpoints in a cluster, hit them directly: curl localhost:3000/health/live should return a 200 with status ok under normal conditions, and you can force a readiness failure by stopping your local database container and watching /health/ready flip to a 503 with the database indicator reporting down. This quick manual check catches configuration mistakes — like accidentally wiring a database ping into the liveness path — before they cause a production incident.
Terminus is a small package, but getting the liveness/readiness split right is one of those details that separates a service that degrades gracefully under partial outages from one that cascades into a full restart storm the first time a dependency hiccups.