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)