Health checks with Terminus
I learned the hard way that a process responding to requests doesn't mean your app is ready to handle them. When I deployed a NestJS service without proper health checks, Kubernetes kept routing traffic to instances that had lost their database connection, causing cascading failures across the platform. In this article, I'll show you how to implement liveness and readiness probes using @nestjs/terminus—the official NestJS health check library—so your infrastructure can make intelligent decisions about where to send traffic.
What Terminus does for you
Terminus is a NestJS module that provides a standardized way to check the health of your application and its dependencies. Instead of writing custom health endpoints yourself, Terminus gives you pre-built indicators for the most common failure points: database connectivity, memory usage, disk space, and HTTP service availability.
The key insight is separation of concerns. A readiness probe should answer: "Is this instance ready to accept user traffic right now?" A liveness probe should answer: "Is the process still running?" These are fundamentally different checks that orchestrators like Kubernetes need to make separate decisions.
Install Terminus and its indicators
Start by adding the package and a database adapter. I'll use Prisma as an example, but Terminus supports TypeORM, Sequelize, MongoDB, and others.
npm install @nestjs/terminus
npm install @nestjs/terminus-prisma # or your DB adapter
Then import TerminusModule into your AppModule:
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [
TerminusModule,
// your other modules
],
controllers: [HealthController],
})
export class AppModule {}
Terminus will be available as a service you can inject into controllers. The module handles all the scaffolding so you focus on what to check.
A readiness endpoint—what the app needs to serve traffic
The readiness probe should check every critical dependency your app relies on before handling a request. If your e-commerce platform can't reach its payment provider or database, it shouldn't claim to be ready. In my experience, this is where most deployments go wrong: people either check too little (missing real failure modes) or check too much (making deployments take forever).
Here's a practical readiness check:
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { PrismaHealthIndicator } from '@nestjs/terminus-prisma';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: PrismaHealthIndicator,
) {}
@Get('ready')
@HealthCheck()
async readiness() {
return this.health.check([
() => this.db.pingCheck('database', { prisma: this.prismaService }),
() => this.memoryHealthIndicator.checkHeap('heap', 300 * 1024 * 1024),
() => this.memoryHealthIndicator.checkRss('rss', 500 * 1024 * 1024),
]);
}
}
Breaking this down:
- Database ping: Attempts a simple query. If it fails, the pod isn't ready.
- Heap memory: Fails if heap usage exceeds 300 MB. Adjust this based on your workload.
- RSS (resident set size): Total memory usage. This is more generous than heap, but still a safety rail.
The @HealthCheck() decorator tells NestJS to wrap the response in Terminus's standard format. A successful check returns HTTP 200 with a payload like:
{
"status": "ok",
"checks": {
"database": {
"status": "up",
"message": "Connection OK"
},
"heap": {
"status": "up",
"value": 45000000
},
"rss": {
"status": "up",
"value": 120000000
}
}
}
Any failed check returns HTTP 503 (Service Unavailable), which tells Kubernetes to immediately stop routing traffic to that pod. In a microservices environment, I've seen this single change reduce incident response time by 80% because we catch bad deployments in seconds instead of minutes.
Liveness stays simple—the process is alive if it responds
The liveness probe is intentionally lightweight. It should only verify that the process itself is running. Do not check external dependencies here. If your database is temporarily down, your app process is still alive, and Kubernetes should not restart it. Restarting won't fix a database outage—it just creates more cascading failures.
@Get('alive')
@HealthCheck()
async liveness() {
return this.health.check([
() => this.memory.checkHeap('heap', 1024 * 1024 * 1024), // 1 GB hard ceiling
]);
}
That's it. A liveness check often includes:
- A simple memory threshold to catch out-of-control memory leaks.
- Optionally, a counter or timestamp proving the event loop isn't blocked.
- Nothing that touches the network or I/O.
The philosophy is: if your process is still responsive and not starving for memory, let it live. The readiness endpoint is your gatekeeper for traffic.
Wire into Kubernetes—probes for intelligent orchestration
Once your endpoints are ready, configure your Kubernetes deployment to use them:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
template:
spec:
containers:
- name: api
image: api:v1
ports:
- containerPort: 3000
livenessProbe:
httpGet:
path: /health/alive
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
Key settings:
- initialDelaySeconds: Give the app time to start before probing. Readiness is shorter (app boots fast), liveness is longer (wait for migrations).
- periodSeconds: How often to check. Readiness checks frequently (5s) to catch failures quickly. Liveness is slower (10s) to reduce noise.
- failureThreshold: How many failed probes before action. Readiness fails faster (2), liveness more tolerant (3).
Now Kubernetes can:
- Wait for readiness before sending traffic (rolling deployments work smoothly).
- Remove unready pods from the load balancer without restarting them.
- Restart only if liveness fails (something is truly broken).
Common mistakes
Mixing liveness and readiness: A common trap is putting dependency checks in both. If your cache layer goes down temporarily, your readiness probe fails (correct), but if you also check it in liveness, Kubernetes restarts the pod (wrong). Keep liveness focused on the process itself.
Thresholds that are too strict: I once set memory limits so tight that normal request spikes caused readiness failures, leading to thrashing as pods constantly flipped in and out of the load balancer. Test your thresholds under realistic load before deploying.
Forgetting to handle transient failures: A database taking 2 seconds longer to respond shouldn't immediately fail readiness. Consider adding a retry or a small timeout buffer in your checks.
No observability: Log or export metrics on health check results. When a pod keeps failing readiness, you want to know why immediately, not discover it in a post-mortem.
Key takeaways
- Use @nestjs/terminus to standardize health checks instead of writing custom logic.
- Readiness probes check dependencies (database, cache, external services). Liveness probes check only the process itself.
- Map readiness to Kubernetes
readinessProbeand liveness tolivenessProbein your deployment manifest. - Set appropriate timeouts and thresholds based on your workload; too strict causes thrashing, too loose misses real failures.
- Keep liveness simple and fast; reserve dependency checks for readiness.
- Monitor and alert on health check failures so you catch issues before users do.






Comments
Loading comments…