Skip to content

A NestJS Production Checklist

Aman Kumar Singh7 min read
Part 40 of 40From the NestJS Production Guide series
A NestJS Production Checklist — article by Aman Kumar Singh

In Deploying NestJS to Production, we walked through getting a NestJS app onto real infrastructure: containerizing it, wiring up CI/CD, and picking a rollout strategy. That article answers "how does this get to a server." This one answers a different question: "is it actually ready to be there."

This is part of the NestJS Production Guide series, which started with NestJS Architecture Overview. Across that series we covered modules, guards, pipes, validation, Redis, queues, CQRS, and microservices one topic at a time. Each of those decisions is defensible in isolation. Production readiness is a property of the whole system: whether the pieces add up to something that survives a bad deploy, a traffic spike, or a dependency going down at 3 a.m.

I've organized this as a checklist rather than a narrative, because that's how I actually use it: as a gate before a service goes live, and again before every major release. Treat the sections below as the minimum bar, not an exhaustive audit. Some of these will already be handled by your platform team's defaults; some you'll have to build yourself.

Configuration and secrets are validated at boot, not at runtime

A NestJS app that reads process.env.DATABASE_URL directly and only discovers it's missing when the first query fails has pushed a configuration problem into a runtime incident. The fix is to validate configuration once, at startup, and fail fast if anything is wrong.

@nestjs/config supports a validation schema out of the box. I use class-validator here because it matches the DTO validation style already used everywhere else in the app, though Joi or Zod work just as well.

// config/env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsInt, IsString, Max, Min, validateSync } from 'class-validator';

enum Environment {
  Development = 'development',
  Staging = 'staging',
  Production = 'production',
}

class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment;

  @IsInt()
  @Min(1)
  @Max(65535)
  PORT: number;

  @IsString()
  DATABASE_URL: string;

  @IsString()
  REDIS_URL: string;

  @IsString()
  JWT_SECRET: string;
}

export function validateEnv(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated, { skipMissingProperties: false });

  if (errors.length > 0) {
    throw new Error(`Invalid environment configuration: ${errors.toString()}`);
  }

  return validated;
}
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validateEnv } from './config/env.validation';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validate: validateEnv,
    }),
  ],
})
export class AppModule {}

The tradeoff is a small amount of upfront boilerplate for every new environment variable. That's the right trade. A container that refuses to start because JWT_SECRET is undefined is a two-minute fix caught in a deploy pipeline. The same problem discovered by a user hitting a 500 on login is an incident with a postmortem.

One pitfall worth calling out: don't validate secrets for shape only. Checking that JWT_SECRET is a non-empty string catches a missing variable, but it won't catch a development placeholder value accidentally carried into production. If your platform supports it, pull secrets from a secrets manager (AWS Secrets Manager, Parameter Store) rather than plain environment variables, and keep the validation layer as a safety net rather than the only line of defense.

Global request hardening is set once, not per-controller

Guards, pipes, and interceptors are powerful precisely because they can be scoped narrowly, but that's also how security gaps happen: a new controller ships without the ValidationPipe because nobody remembered to add it. The fix is to set defaults globally in main.ts and only override per-route when there's a real reason to.

// main.ts
import helmet from 'helmet';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
  });

  app.use(helmet());

  app.enableCors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [],
    credentials: true,
  });

  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  app.enableShutdownHooks();

  const port = process.env.PORT ?? 3000;
  await app.listen(port);
}

bootstrap();

whitelist: true and forbidNonWhitelisted: true together mean the app rejects any field in a request body that isn't in the DTO, instead of silently dropping it or, worse, passing it through to a database write. That single setting closes off a class of mass-assignment bugs without touching a single controller.

The tradeoff is that global defaults are blunt instruments. A public health-check endpoint doesn't need CORS restrictions, and an internal admin route might need a stricter rate limit than the rest of the API. Handle those as explicit exceptions on the route, documented with a comment explaining why, rather than loosening the global default for convenience.

Health checks distinguish liveness from readiness

Kubernetes, ECS, and most orchestrators need two different questions answered, and conflating them is a common production pitfall. Liveness asks "is this process alive enough to keep running," and readiness asks "should traffic be routed here right now." A NestJS instance that's alive but can't reach PostgreSQL should fail its readiness check and get pulled from the load balancer. Restarting it won't fix a database outage.

@nestjs/terminus gives you this distinction directly:

// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  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,
  ) {}

  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([() => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024)]);
  }

  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

The pitfall I see most often is a single /health endpoint that checks the database and gets wired to the liveness probe. When the database has a brief blip, every pod fails liveness and gets restarted simultaneously, which turns a transient dependency issue into a full outage from a thundering herd of reconnecting instances. Keep liveness cheap and local; keep readiness dependency-aware.

Graceful shutdown actually drains in-flight work

app.enableShutdownHooks() from the snippet above wires up NestJS's lifecycle events (OnModuleDestroy, beforeApplicationShutdown) to process signals like SIGTERM. Without it, a rolling deploy or a scale-down event kills the process the instant the orchestrator sends the signal, dropping any request that's mid-flight and any job that was mid-processing.

The part that's easy to miss is that enabling shutdown hooks only wires the event; it doesn't automatically make everything drain cleanly. If you're running a BullMQ worker inside the same process, you still need to explicitly close the worker and let it finish its current job before the process exits:

// jobs/email.processor.ts
import { OnApplicationShutdown } from '@nestjs/common';
import { Worker } from 'bullmq';

export class EmailProcessor implements OnApplicationShutdown {
  private readonly worker: Worker;

  async onApplicationShutdown(signal?: string) {
    await this.worker.close();
  }
}

The tradeoff is time. A worker that's mid-job when SIGTERM arrives needs a grace period before the orchestrator sends SIGKILL, and that grace period has to be configured on the platform side (Kubernetes' terminationGracePeriodSeconds, ECS's stop timeout) to match how long your longest job realistically takes. Set it too short and you're back to dropping work; set it unnecessarily long and rollouts get slower than they need to be.

Logging is structured and correlated across a request

By the time an app reaches production, it almost always has logging. The question that separates useful logging from noise is whether you can trace a single request across every log line it touched, and whether those log lines are structured enough to query. Plain string logs ("User logged in") are fine for local development and nearly useless when you're trying to filter ten thousand log lines by request ID during an incident.

A request-scoped correlation ID, attached via middleware and propagated through NestJS's logger, solves this without changing how any individual service calls this.logger.log(...):

// logging/correlation-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const correlationId = (req.headers['x-correlation-id'] as string) ?? randomUUID();
    req['correlationId'] = correlationId;
    res.setHeader('x-correlation-id', correlationId);
    next();
  }
}

Pair this with a JSON log formatter (pino or Winston with a JSON transport) so every log line is a structured object with correlationId, level, timestamp, and context as fields your log aggregator can filter on directly, instead of a string you have to grep. The tradeoff is that structured JSON logs are harder to eyeball in a local terminal, which is why most setups use a pretty-print transport locally and JSON in every deployed environment.

Key takeaways

  • Validate environment configuration at boot with a schema, so a missing secret is a failed deploy, not a runtime 500.
  • Set security defaults (`ValidationPipe`, `helmet`, CORS, versioning) globally in `main.ts` rather than trusting every new controller to add them individually.
  • Split health checks into liveness (is the process alive) and readiness (should it receive traffic), and never wire a dependency check to liveness.
  • Call `enableShutdownHooks()` and explicitly drain background workers and queue consumers so rolling deploys don't drop in-flight work.
  • Use structured, correlated logging so a single request can be traced deliberately across every log line during an incident, rather than found by luck.
  • Treat this checklist as a gate before every major release, not a one-time setup task; each new integration (a new queue, a new external API) usually adds one more item to it.

Frequently asked questions

What's the minimum NestJS setup needed before going to production?

At a minimum: global validation pipes with `whitelist` and `forbidNonWhitelisted`, environment variable validation at startup, `helmet()` for HTTP header hardening, a readiness health check that verifies the database and Redis connections, and `enableShutdownHooks()` so deploys don't drop in-flight requests. Everything beyond that (structured logging, correlation IDs, per-route rate limiting) matters, but these five cover the failure modes that show up fastest.

Do I need @nestjs/terminus, or can I write health checks by hand?

You can write them by hand with a plain controller that pings your database and Redis client, and for a very small service that's a reasonable choice. `@nestjs/terminus` earns its place once you have more than two or three dependencies to check, because it standardizes the response format and gives you composable indicators instead of custom logic duplicated across health endpoints.

Why does my pod keep restarting even though the app seems fine?

This is almost always a liveness probe checking something it shouldn't. If your liveness endpoint pings the database and the database has a brief network blip, every instance fails liveness at roughly the same time and gets restarted together, which can turn a two-second database hiccup into a multi-minute outage as instances reconnect. Liveness should only check that the process itself is responsive; leave dependency checks to readiness.

Is helmet() actually necessary if I'm already behind a CDN or API gateway?

Some CDNs and gateways set a subset of security headers by default, but coverage varies and configurations drift over time. `helmet()` is cheap to apply and makes the header set explicit in your codebase rather than implicit in infrastructure someone else configures, which matters when the app is deployed to a new environment that doesn't have the same gateway in front of it.

How long should my termination grace period be?

Long enough to cover your longest realistic in-flight unit of work, whether that's an HTTP request or a queued job, plus a margin for the shutdown hooks themselves to run. If your longest job typically takes under a minute, a grace period of 60 to 90 seconds is a reasonable starting point; the important thing is that it's derived from your actual workload rather than copied from an unrelated project's defaults.

Should this checklist be automated in CI, or is it a manual review?

Parts of it should absolutely be automated: a CI step that fails the build if `main.ts` doesn't call `enableShutdownHooks()`, or a test that boots the app with an intentionally invalid environment variable and asserts it throws. The parts that are harder to automate, like whether the termination grace period actually matches your workload, belong in a release checklist reviewed by a person before a major deploy.

Related articles

gRPC Services in NestJS — Aman Kumar Singh
Microservices with NestJS — Aman Kumar Singh
Event-Driven Architecture — 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.