NestJS — Enterprise Node.js

Deployment: Docker, Monorepo & Serverless

16 min Lesson 48 of 48

Deployment: Docker, Monorepo & Serverless

Getting a NestJS application from your laptop to production involves three interlocking skills: packaging it reproducibly with Docker, organising a large codebase as a Nest monorepo, and optionally deploying as a serverless function on AWS Lambda. This lesson walks through all three with production-quality examples.

Multi-stage Dockerfile

A naive single-stage Docker build copies the entire node_modules folder — including thousands of dev dependencies — into the image, inflating it to hundreds of megabytes. A multi-stage build uses separate build and runtime stages so the final image contains only what is needed:

# ---- build stage ---- FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # produces dist/ # ---- runtime stage ---- FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev # production deps only COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/main"]
Always use npm ci, not npm install. npm ci installs exactly what is in package-lock.json, giving you a reproducible, deterministic build every time. npm install may silently update patch versions.

The final image copies only dist/ and production node_modules from the builder layer. The result is typically under 200 MB versus 600+ MB for a naive build.

Production configuration

Hard-coding secrets or URLs is never acceptable. Use environment variables and NestJS's ConfigModule — configured once at the module level with validation via Joi or a class-validator schema:

// app.module.ts import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), DATABASE_URL: Joi.string().uri().required(), JWT_SECRET: Joi.string().min(32).required(), PORT: Joi.number().default(3000), }), }), // ...other modules ], }) export class AppModule {}
Never commit .env files to version control. Store secrets in your CI/CD pipeline's secret manager (GitHub Actions Secrets, AWS Secrets Manager, etc.) and inject them at runtime as environment variables. A leaked .env in git history is extremely difficult to purge safely.

Nest monorepo workspaces

As your project grows you may need multiple deployable applications (e.g. an API server and a background worker) sharing common libraries. Nest's CLI supports a monorepo mode that manages this with a single nest-cli.json:

  • apps/ — each subdirectory is an independent deployable application with its own main.ts.
  • libs/ — shared code (DTOs, utilities, database models) imported by any app via @app/shared-style path aliases.
  • Build a specific app: nest build api — output goes to dist/apps/api/.
  • Run in dev: nest start api --watch while simultaneously nest start worker --watch.
Monorepo vs polyrepo. A monorepo keeps all code in one git repository, making atomic cross-app refactors trivial. It requires more careful CI configuration (only rebuild changed apps). For small teams a well-structured monorepo is usually the right default.

Serverless deployment on AWS Lambda

Serverless removes the need to manage server instances. NestJS can run on AWS Lambda using the @nestjs/platform-express or @vendia/serverless-express adapter, which wraps the Express application and translates Lambda events into HTTP requests:

// lambda.ts (entry point — replaces main.ts for Lambda builds) import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import serverlessExpress from '@vendia/serverless-express'; import * as express from 'express'; import { AppModule } from './app.module'; import { Handler } from 'aws-lambda'; let cachedServer: Handler; async function bootstrapLambda(): Promise<Handler> { const expressApp = express(); const adapter = new ExpressAdapter(expressApp); const nestApp = await NestFactory.create(AppModule, adapter); nestApp.enableCors(); await nestApp.init(); return serverlessExpress({ app: expressApp }); } export const handler: Handler = async (event, context) => { cachedServer = cachedServer ?? (await bootstrapLambda()); return cachedServer(event, context); };
Cache the server instance. Lambda reuses the same container across multiple invocations (warm starts). Caching the bootstrapped NestJS application in a module-level variable avoids re-initialising the DI container on every request, reducing cold-start latency dramatically.

Choosing a deployment target

  • Containerised (Docker + ECS / Kubernetes) — best for long-lived, stateful connections (WebSockets, SSE), predictable latency, and large applications.
  • Serverless (Lambda / Cloud Run) — best for event-driven APIs with bursty traffic, minimal ops overhead, and pay-per-request billing.
  • Monorepo — orthogonal to both; defines how you organise code, not where it runs.

Summary

A production NestJS deployment combines: a multi-stage Dockerfile that keeps the final image lean by separating build from runtime, ConfigModule with schema validation to ensure all required environment variables are present and correctly typed, an optional Nest monorepo to share code between multiple apps via apps/ and libs/ directories, and a serverless adapter (@vendia/serverless-express) when targeting AWS Lambda with a cached bootstrap for low cold-start latency. Choose your deployment target based on traffic patterns and operational requirements.

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.