Deploying NestJS to Production
In Security Best Practices I covered hardening the application itself: input validation, headers, rate limiting, and the usual OWASP checklist. None of that matters if the way you ship the app introduces its own holes, so this post is about the deployment path itself, part of the NestJS Production Guide series.
Deployment is one of those areas where teams either over-invest early (a full Kubernetes cluster for an app with a hundred users) or under-invest for too long (manually SSHing in and running pm2 restart well past the point that's sustainable). Neither is wrong at every stage. What matters is picking the simplest setup that gets you reliable builds, safe rollouts, and a rollback path, and upgrading only when the current one is visibly straining.
This article walks through building a production Docker image for a NestJS app, handling configuration and secrets at runtime instead of build time, and shutting down cleanly so deploys don't drop in-flight requests.
Choosing a deployment target
Before touching a Dockerfile, decide where the container is actually going to run, because that choice shapes almost everything downstream: how you handle secrets, how health checks are wired, and how rollbacks work.
For a single NestJS API backing a SaaS product, three options come up repeatedly:
- A single VM or a small fleet behind a load balancer, running the container via Docker Compose or a systemd unit. Simple to reason about, cheap, and entirely sufficient for a service that doesn't need to scale horizontally on demand yet.
- A managed container platform like AWS ECS on Fargate. You get rolling deploys, health-check-driven rollback, and autoscaling without managing servers, in exchange for learning ECS's task definitions and service configuration.
- Kubernetes, self-managed or via EKS. The most flexible and the most operationally expensive. Worth it once you have multiple services, need fine-grained scheduling, or already have platform engineers maintaining it.
I'd default to ECS on Fargate for most SaaS backends at this stage: it removes the instance-management burden and plays well with an existing VPC and RDS/ElastiCache setup, without the Kubernetes learning curve. Kubernetes earns its complexity once a shared platform team can spread that investment across many services. A single API rarely justifies it as a starting point.
Whichever target you pick, the container image itself should be identical. That's the part worth getting right regardless of where it runs.
Building a production Docker image
A NestJS project has dependencies you need at build time (the TypeScript compiler, Nest CLI, dev types) and a much smaller set you need at runtime. Shipping the build-time dependencies into your production image bloats it and widens the attack surface for no benefit. A multi-stage Dockerfile solves this cleanly.
# Dockerfile
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production stage ----
FROM node:20-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# Run as a non-root user rather than the container default
RUN addgroup -S nodejs && adduser -S nestjs -G nodejs
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main.js"]
A few details here matter more than they look:
npm ciinstead ofnpm installguarantees the build uses exactly what's inpackage-lock.json. Reproducible builds are non-negotiable once you have more than one environment.- The final image never contains the TypeScript source, the Nest CLI, or dev dependencies like
jestoreslint. Onlydist/and productionnode_modulesship. - Running as a non-root user is a small change that closes off a real class of container escape issues, and most base images make it a one-line addition.
node:20-alpinekeeps the image small. But if you rely on native modules with problematic musl compatibility (some cryptography or image-processing packages), test this combination before committing to it.node:20-slim(Debian-based) is the fallback if Alpine causes native build issues.
Add a .dockerignore alongside this so node_modules, .env, and .git never get copied into the build context:
node_modules
dist
.env
.env.*
.git
*.md
Configuration and secrets at runtime, not build time
An image built once should be deployable to staging and production unchanged, with only the environment differing. That means configuration values, database URLs, Redis connection strings, JWT secrets, third-party API keys, must come from the environment at container start, never baked into the image during docker build.
NestJS's @nestjs/config module combined with a validation schema catches misconfiguration at boot instead of at the first request that touches the missing value:
// config/env.validation.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type EnvConfig = z.infer<typeof envSchema>;
export function validateEnv(config: Record<string, unknown>): EnvConfig {
const result = envSchema.safeParse(config);
if (!result.success) {
throw new Error(
`Invalid environment configuration: ${result.error.message}`,
);
}
return result.data;
}
// 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 worth naming here: throwing on startup instead of at first use means a bad deploy fails fast and loud, which is exactly what you want in production. It also means a misconfigured environment variable will crash every instance at the same time instead of degrading gracefully. That's fine, as long as your deployment platform's health checks catch the crash loop before it takes down the whole fleet. That's exactly why the next section matters.
For where the actual secret values live: environment variables injected by the orchestrator (ECS task definitions pulling from AWS Secrets Manager or SSM Parameter Store, Kubernetes Secrets, or a .env file on a single VM that's excluded from version control) is the right layer. Never commit a populated .env file, and never pass secrets as Docker build arguments, since build args and intermediate layers can leak them into image history even after a later RUN overwrites them.
Graceful shutdown and zero-downtime deploys
This is the part that's easy to skip and painful to debug later. When your deployment platform rolls out a new version, it sends the old container a SIGTERM and expects it to finish in-flight requests before exiting. If your app doesn't handle that signal, the container gets killed mid-request, and clients see connection resets during every deploy.
NestJS exposes shutdown hooks for exactly this, but they're opt-in:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
With enableShutdownHooks() on, any provider implementing OnModuleDestroy or beforeApplicationShutdown gets a chance to clean up before the process exits, closing database pools, flushing pending Redis writes, letting outstanding HTTP requests complete.
// database/prisma.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
async onModuleDestroy() {
await this.$disconnect();
}
}
The other half of zero-downtime deploys lives outside NestJS entirely: your load balancer or orchestrator needs to stop sending new traffic to a container before it starts shutting down, not after. On ECS, this means configuring a deregistration delay on the target group long enough for in-flight requests to finish. On Kubernetes, it means a preStop hook with a short sleep to let the endpoint controller propagate the pod's removal before SIGTERM fires. Skipping this step means requests can still land on a container that's already begun tearing down, regardless of how clean your shutdown hooks are.
Rolling out safely: health checks and rollback
A deploy pipeline without an automated rollback path is just a way to find out about production incidents faster. The mechanism that makes rollback possible is a health check the orchestrator polls before it finishes cutting traffic over to the new version.
A minimal readiness endpoint (covered in more depth in the health-checks article in this series) needs to verify the things that would actually make the app unable to serve traffic: database connectivity, and Redis connectivity if the app depends on it for sessions or rate limiting.
// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheckService, HealthCheck, TypeOrmHealthIndicator } from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
) {}
@Get('ready')
@HealthCheck()
check() {
return this.health.check([() => this.db.pingCheck('database')]);
}
}
Wire this into the deployment platform's health check configuration (ECS target group health check path, Kubernetes readiness probe) and set a deploy strategy that keeps the old version running until enough new instances report healthy. If the new version fails its health check within a reasonable window, the orchestrator rolls back automatically instead of a human noticing an error spike ten minutes later.
The pitfall worth flagging: a health check that only returns 200 OK unconditionally tells you the process is running, not that it can serve real requests. I've seen teams add a database check that's genuinely rigorous, then leave out the check for a secondary dependency the app actually needs (Redis for sessions, an external payment provider for a critical path), and get burned when that dependency is the one that goes down. Match your health check's scope to what "unable to serve traffic" actually means for your app.
Key takeaways
- Pick the simplest deployment target that gives you reliable builds and a rollback path; ECS on Fargate is a reasonable default before Kubernetes earns its complexity.
- Use a multi-stage Dockerfile so the production image ships only compiled output and production dependencies, not the build toolchain.
- Validate environment variables at startup with a schema so misconfiguration fails loudly at boot instead of silently at the first request that touches it.
- Never bake secrets into the image or pass them as Docker build args; inject them at runtime from your orchestrator's secret store.
- Call `enableShutdownHooks()` and implement `OnModuleDestroy` for anything holding a connection, then pair it with a deregistration delay or `preStop` hook so traffic actually stops before the container exits.
- A health check needs to reflect real dependencies like the database and Redis, since a check that only confirms process liveness lets automated rollback miss the failures that actually matter.
Frequently asked questions
Do I need Kubernetes to run NestJS in production?
No. A single NestJS API can run reliably on a small VM fleet behind a load balancer or on a managed platform like AWS ECS on Fargate. Kubernetes is worth adopting once you're running enough services that a shared platform team's investment pays off across all of them, not as a default for one API.
Why use a multi-stage Docker build instead of just installing everything in one stage?
A single-stage build ships the TypeScript compiler, dev dependencies, and source files into production, which bloats the image and expands the attack surface. A multi-stage build compiles in one stage and copies only `dist/` and production `node_modules` into the final image, keeping it smaller and closer to what actually runs.
How should I handle secrets like database passwords and JWT signing keys?
Inject them as environment variables at container start, sourced from your orchestrator's secret store (AWS Secrets Manager, SSM Parameter Store, Kubernetes Secrets). Never commit them to version control, bake them into the Docker image, or pass them as build arguments, since build args can persist in image layer history.
What does `enableShutdownHooks()` actually fix?
Without it, NestJS's lifecycle hooks like `OnModuleDestroy` never run when the process receives `SIGTERM`, so database connections and other resources get torn down abruptly instead of cleanly. Enabling it lets providers close connections and finish in-flight work before the process exits, which is what makes rolling deploys avoid dropped requests.
Why do I still see dropped requests during deploys even with graceful shutdown enabled?
Usually because the load balancer or orchestrator is still routing new traffic to the container after `SIGTERM` is sent but before it finishes exiting. Configure a deregistration delay on the target group (ECS) or a `preStop` hook (Kubernetes) so traffic stops before the shutdown sequence begins, not concurrently with it.
What should a production health check actually verify?
Whatever your app genuinely needs to serve a request: database connectivity at minimum, plus Redis or any other dependency that would make the app functionally broken if it were down. A health check that only confirms the process is alive won't trigger automated rollback for the failures that actually matter to users.
Further reading
Explore more on

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.