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.
Installing and wiring up Terminus
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 {}Building the HealthController
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.
Database connectivity: TypeOrmHealthIndicator
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.
Process health: memory and disk indicators
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.
Liveness vs readiness: the distinction that actually matters
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.
Mapping probes to Docker and Kubernetes config
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: 3Startup probes for slow-booting services
A 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.
Testing the endpoints locally
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.
Sources
- NestJS official documentation — Terminus healthchecks recipe (docs.nestjs.com)
- NestJS official documentation — Terminus migration guide, HealthIndicatorService (docs.nestjs.com)
- Kubernetes documentation — Configure Liveness, Readiness and Startup Probes (kubernetes.io)
- Docker documentation — Dockerfile reference, HEALTHCHECK instruction (docs.docker.com)

