Skip to content

nest_15_deploy - Deployment

Your NestJS app works on your machine Now make it work everywhere else - behind a reverse proxy , in a container , with environment-specific configs and without exposing your process.env to anyone who finds the right endpoint Deployment is where good architecture meets operational reality , and operational reality is brutal about configs you forgot to change

what's in here

  • Building for production (nest build)
  • PM2 process management
  • Docker multi-stage builds
  • Nginx reverse proxy configuration
  • Environment configuration across environments
  • Health checks and graceful shutdown

building for production

npm run build
# compiles TypeScript to dist/
# outputs: dist/main.js + all compiled modules
{
  "scripts": {
    "build": "nest build",
    "start:prod": "node dist/main.js",
    "start:prod:debug": "NODE_OPTIONS='--inspect' node dist/main.js"
  }
}

nest build compiles TypeScript with the tsconfig.build.json config (excludes tests , stricter output) The output goes to dist/ - only this directory , your package.json , and node_modules/ are needed in production Never deploy your TypeScript source files - the runtime runs compiled JavaScript

environment configuration

# .env.development
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
JWT_SECRET=dev-secret-change-me

# .env.production
NODE_ENV=production
DB_HOST=db.internal.example.com
DB_PORT=5432
JWT_SECRET=your-256-bit-secret-generated-by-pwgen
// app.module.ts
ConfigModule.forRoot({
  envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
  isGlobal: true,
  validate: (config) => {
    // validate required env vars at startup
    const required = ['DB_HOST', 'DB_PORT', 'JWT_SECRET', 'DB_PASSWORD']
    for (const key of required) {
      if (!config[key]) {
        throw new Error(`Missing required env var: ${key}`)
      }
    }
    return config
  }
})

Environment-specific .env files prevent accidental config sharing Validate required variables at bootstrap - fail fast with a clear message instead of mysteriously crashing at the first database query Never commit .env.production to git - generate it from your secrets manager in CI/CD

Docker multi-stage build

# Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
COPY tsconfig*.json ./
COPY nest-cli.json ./
COPY src/ ./src/

RUN npm ci --only=production
RUN npm ci  # install devDependencies for build
RUN npm run build
RUN npm prune --production  # remove devDependencies after build

# Stage 2: Production
FROM node:20-alpine

WORKDIR /app

# security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# copy only what's needed for production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER appuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://localhost:3000/api/v1/health').then(r => {process.exit(r.ok ? 0 : 1)}).catch(() => process.exit(1))"

CMD ["node", "dist/main.js"]

Multi-stage builds reduce the final image size - no dev dependencies , no TypeScript source , no build tools The builder stage compiles everything , then only dist/ , node_modules/ (production-only) , and package.json go into the final image Non-root user inside the container prevents privilege escalation if the app is compromised HEALTHCHECK tells Docker/K8s when your app is ready and when it's dead

docker-compose for local deployment testing

# docker-compose.yml
version: '3.8'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: app
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: nestapp
      JWT_SECRET: ${JWT_SECRET}
      ALLOWED_ORIGINS: https://app.example.com
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: nestapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - api

volumes:
  pgdata:

Docker Compose is the staging environment - test your full stack before deployment Never hardcode secrets in docker-compose - use ${VARIABLE} interpolation with a .env file Health checks ensure your app doesn't receive traffic before it's ready

Nginx reverse proxy

# nginx.conf
upstream nest_api {
    least_conn;
    server api:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name api.example.com;
    return 301 https://$server_name$request_uri;  # force HTTPS
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # security headers (proxied from NestJS + additional)
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-Content-Type-Options nosniff;

    location / {
        proxy_pass http://nest_api;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # timeout settings
        proxy_connect_timeout 10s;
        proxy_read_timeout 30s;
        proxy_send_timeout 30s;

        # rate limiting (parallel to NestJS throttler)
        limit_req zone=api_limit burst=20 nodelay;
    }

    location /health {
        proxy_pass http://nest_api;
        access_log off;  # health checks don't need logging
    }

    location /api/v1/admin {
        # additional rate limit for admin endpoints
        limit_req zone=admin_limit burst=5 nodelay;
        proxy_pass http://nest_api;
    }
}

Nginx handles SSL termination - your NestJS app doesn't need to deal with certificates proxy_set_header X-Real-IP sends the real client IP - critical for rate limiting and audit logs Rate limiting at Nginx level catches traffic before it reaches your app (defense in depth) HTTP/2 reduces latency for modern clients

forward headers in NestJS

// main.ts - must configure this when behind reverse proxy
async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  // trust proxy headers from Nginx/ELB
  app.getHttpAdapter().getInstance().set('trust proxy', 1)

  await app.listen(3000)
}

Without trust proxy , your app sees all traffic coming from Nginx's IP (e.g., 172.17.0.x) Rate limiting fails because every request looks like it's from the same IP trust proxy: 1 trusts the first proxy in the X-Forwarded-For chain - adjust for your proxy architecture

PM2 process management

npm install -g pm2
// ecosystem.config.json
{
  "apps": [{
    "name": "nest-api",
    "script": "dist/main.js",
    "instances": "max",
    "exec_mode": "cluster",
    "env": {
      "NODE_ENV": "production"
    },
    "max_memory_restart": "500M",
    "error_file": "/var/log/nest-api/error.log",
    "out_file": "/var/log/nest-api/output.log",
    "merge_logs": true,
    "log_date_format": "YYYY-MM-DD HH:mm:ss Z",
    "listen_timeout": 3000,
    "kill_timeout": 5000
  }]
}
pm2 start ecosystem.config.json
pm2 save
pm2 startup  # restart on server reboot

PM2 runs your app in cluster mode - instances: max spawns one worker per CPU core Zero-downtime reload: pm2 reload ecosystem.config.json restarts workers one by one Auto-restart on crash means a segfault in a dependency doesn't take down your entire API Log rotation with pm2-logrotate prevents disk fills

graceful shutdown

import { Injectable, OnApplicationShutdown } from '@nestjs/common'
import { PrismaService } from './prisma.service'

@Injectable()
export class ShutdownService implements OnApplicationShutdown {
  constructor(private prisma: PrismaService) {}

  async onApplicationShutdown(signal?: string) {
    console.log(`Received ${signal} - shutting down gracefully`)

    // close database connections
    await this.prisma.$disconnect()

    // flush pending logs
    console.log('Shutdown complete')
  }
}

NestJS calls onApplicationShutdown when the process receives SIGTERM or SIGINT Close database connections , flush logs , complete in-flight requests Without graceful shutdown , killing your app mid-request leaves zombie connections and corrupts state

health check endpoint

import { Controller, Get } from '@nestjs/common'
import { SkipThrottle } from '@nestjs/throttler'
import { HealthCheckService, HealthCheck, TypeOrmHealthIndicator } from '@nestjs/terminus'

@Controller('health')
@SkipThrottle()  // never rate limit health checks
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 3000 })
    ])
  }
}

Health checks tell your load balancer whether this instance should receive traffic Separate health endpoint - not behind auth , not rate limited , minimal dependencies Include database connectivity check - an app with a dead database is not healthy

prerequisites

nest_14_testing - Testing


next -> nest_00_home - NestJS HOME (full loop)