Skip to content
NestJS ns deployment 5 min read

Graceful Shutdown

When an orchestrator redeploys your app it sends a termination signal and gives the process a few seconds to exit before forcibly killing it. If your NestJS app exits immediately, in-flight requests get dropped, database transactions are abandoned, and message consumers leave records half-processed. Graceful shutdown is the discipline of reacting to that signal: stop accepting new work, let current requests finish, and close every connection cleanly. NestJS makes this manageable with lifecycle hooks that fire on SIGTERM and SIGINT.

How NestJS shutdown hooks work

Nest does not listen for process termination signals by default, because attaching listeners has a small performance cost and not every app needs them. You opt in by calling app.enableShutdownHooks(). Once enabled, when the process receives SIGTERM, SIGINT, or another configured signal, Nest walks the dependency graph in reverse and invokes lifecycle methods on every provider and module that implements them.

Two interfaces matter for cleanup:

HookInterfaceFires whenTypical use
beforeApplicationShutdownBeforeApplicationShutdownRight after the signal, before connections closeStop accepting new jobs, flush buffers
onApplicationShutdownOnApplicationShutdownAfter the above, receives the signal nameClose DB pools, disconnect brokers

Both hooks can return a Promise, and Nest awaits them in sequence, so you can perform genuinely async teardown.

Enabling hooks in main.ts

Enable shutdown hooks during bootstrap. This single call wires up the signal listeners for the whole application.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  // Listen for SIGTERM / SIGINT and run lifecycle hooks
  app.enableShutdownHooks();

  await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
bootstrap();

Containers running node as PID 1 do not always forward SIGTERM. Start the container with docker run --init (or add tini) so the signal actually reaches your process and the hooks fire. Without this, Kubernetes will SIGKILL the pod after the grace period and your cleanup never runs.

Closing database connections and queues

Implement OnApplicationShutdown in the providers that own external resources. The hook receives the signal name, which is useful for logging. Below, a Prisma-backed service disconnects its pool and a queue service drains and closes its connection.

// src/database/prisma.service.ts
import { Injectable, OnApplicationShutdown, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnApplicationShutdown
{
  private readonly logger = new Logger(PrismaService.name);

  async onApplicationShutdown(signal?: string): Promise<void> {
    this.logger.log(`Closing database connection (${signal})`);
    await this.$disconnect();
  }
}
// src/queue/consumer.service.ts
import {
  Injectable,
  BeforeApplicationShutdown,
  OnApplicationShutdown,
  Logger,
} from '@nestjs/common';
import { Queue, Worker } from 'bullmq';

@Injectable()
export class ConsumerService
  implements BeforeApplicationShutdown, OnApplicationShutdown
{
  private readonly logger = new Logger(ConsumerService.name);

  constructor(
    private readonly worker: Worker,
    private readonly queue: Queue,
  ) {}

  // Stop pulling new jobs before connections close
  async beforeApplicationShutdown(): Promise<void> {
    this.logger.log('Pausing worker; no new jobs will be picked up');
    await this.worker.pause(false); // wait for the active job to settle
  }

  // Release the underlying Redis connections
  async onApplicationShutdown(signal?: string): Promise<void> {
    this.logger.log(`Closing queue connections (${signal})`);
    await this.worker.close();
    await this.queue.close();
  }
}

Because beforeApplicationShutdown runs first, the worker stops claiming new jobs while existing ones complete; then onApplicationShutdown tears down the Redis sockets.

Draining in-flight HTTP requests

app.enableShutdownHooks() triggers Nest’s app.close() flow, which stops the HTTP server from accepting new connections and waits for active requests to finish before resolving lifecycle hooks. This is the natural drain mechanism. The risk is the gap between the orchestrator removing the pod from its load balancer and the server actually refusing connections — a short delay before shutdown lets the load balancer catch up so no new request lands on a closing process.

// src/health/shutdown.service.ts
import { Injectable, BeforeApplicationShutdown } from '@nestjs/common';

@Injectable()
export class ShutdownService implements BeforeApplicationShutdown {
  // Give the load balancer time to stop routing to this instance
  async beforeApplicationShutdown(signal?: string): Promise<void> {
    if (signal === 'SIGTERM') {
      await new Promise((resolve) => setTimeout(resolve, 5_000));
    }
  }
}

Pair this with a readiness probe that starts failing the moment a shutdown begins, so Kubernetes removes the pod from the Endpoints list. The combination — failing readiness, a short sleep, then the natural request drain — eliminates dropped traffic during rolling redeploys.

Verifying it works

Run the app and send it SIGTERM with a request in flight; the hooks log their teardown in reverse provider order.

node dist/main.js &
kill -TERM %1

Output:

[Nest] 1  - 06/14/2026, 9:30:11 AM     LOG [ShutdownService] draining for 5000ms
[Nest] 1  - 06/14/2026, 9:30:16 AM     LOG [ConsumerService] Pausing worker; no new jobs will be picked up
[Nest] 1  - 06/14/2026, 9:30:16 AM     LOG [ConsumerService] Closing queue connections (SIGTERM)
[Nest] 1  - 06/14/2026, 9:30:16 AM     LOG [PrismaService] Closing database connection (SIGTERM)
[Nest] 1  - 06/14/2026, 9:30:16 AM     LOG [NestApplication] Nest application shut down gracefully

Tuning the orchestrator grace period

Cleanup only helps if the platform waits long enough. Kubernetes defaults terminationGracePeriodSeconds to 30, which must comfortably exceed your drain delay plus the slowest hook. If a long-running request can take 20 seconds, set the grace period above that or it gets SIGKILLed mid-flight.

spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "5"]

Best Practices

  • Call app.enableShutdownHooks() exactly once during bootstrap; it is a no-op to call more than once but adds clutter.
  • Start the container with --init or tini so SIGTERM actually reaches Node as PID 1.
  • Use beforeApplicationShutdown to stop accepting new work and onApplicationShutdown to close connections, in that order.
  • Make readiness probes fail at shutdown and add a short drain delay so the load balancer stops routing first.
  • Set terminationGracePeriodSeconds larger than your drain delay plus the slowest in-flight request.
  • Always await async teardown (DB $disconnect, broker close) so connections close cleanly instead of being severed.
  • Keep hooks fast and idempotent — they may run during crash-loop restarts as well as planned redeploys.
Last updated June 14, 2026
Was this helpful?