Preparing your NestJS application for production
Deploying NestJS means compiling TypeScript to JavaScript, managing environment-specific configuration, and setting up reliable process management. This guide covers the entire pipeline from local development to a production VPS.
You'll use PM2 to manage the process and restart it if it crashes. Nginx acts as a reverse proxy, listening on ports 80 and 443, forwarding requests to your NestJS app on a local port. Certbot provides SSL certificates from Let's Encrypt automatically. By the end, you'll have a production-grade setup handling traffic securely.
Building NestJS for production
NestJS requires compilation before deployment. The build process transforms TypeScript to JavaScript, optimizes dependencies, and generates a dist/ folder ready to run.
npm run build
This command runs the NestJS compiler using the build script in your package.json. It outputs to dist/ by default, with the entry point typically dist/main.js. The output folder contains all compiled code plus bundled node_modules.
Verify your package.json has the required scripts:
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js"
}
The start:prod script is useful for testing production builds locally before deploying to your VPS.
Test the build locally:
npm run build
npm run start:prod
Your app should start and respond to requests. Kill it with Ctrl+C.
Optimizing the build
NestJS CLI has optimization flags:
"build": "nest build --path tsconfig.prod.json"
Create a tsconfig.prod.json with stricter settings to catch errors:
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"],
"compilerOptions": {
"sourceMap": false,
"declaration": false
}
}
Disabling source maps and declarations saves build time and dist/ size. In production, stack traces use compiled line numbers, which your error tracking service can map back to source.
Transferring code to your VPS
You have two main approaches: Git-based deployment (pull on VPS) or file transfer (SCP, rsync). Git is cleaner for future updates and versioning.
Git-based deployment
SSH into your VPS and clone your repository:
ssh root@your-vps-ip
mkdir -p /var/www/my-nestjs-app
cd /var/www/my-nestjs-app
git clone https://github.com/yourusername/my-nestjs-app.git .
npm install --production
npm run build
Using --production skips devDependencies, saving time and disk space. Ensure your build script is included in dependencies (like @nestjs/cli), not devDependencies, or it will fail.
If your build fails due to missing deps, you may need --production after build instead:
npm install
npm run build
npm prune --production
This installs everything for build, builds, then removes devDependencies afterward.
File transfer with rsync
If you've built locally:
npm run build
rsync -az --exclude node_modules --exclude .git . root@your-vps-ip:/var/www/my-nestjs-app/
Then SSH and install production dependencies on the VPS:
cd /var/www/my-nestjs-app
npm install --production
Creating a PM2 ecosystem file
PM2 needs an ecosystem.config.js file describing how to start and manage your NestJS app.
module.exports = {
apps: [
{
name: 'my-nestjs-app',
script: './dist/main.js',
cwd: '/var/www/my-nestjs-app',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: '/var/log/pm2/err.log',
out_file: '/var/log/pm2/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '500M',
kill_timeout: 10000,
listen_timeout: 10000
}
]
};
Key settings explained:
instances: 'max' runs one PM2 instance per CPU core, leveraging all processors.
exec_mode: 'cluster' enables zero-downtime reloads by restarting workers sequentially.
max_memory_restart: '500M' restarts workers if memory exceeds 500MB, preventing memory leak impact.
kill_timeout: 10000 waits 10 seconds for graceful shutdown before force-killing.
Commit this file to your repository so every deployment uses consistent configuration.
Configuring environment-specific settings
NestJS provides ConfigModule for managing environment variables per deployment stage.
ConfigModule.forRoot() setup
In your AppModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV}`,
cache: true
})
]
})
export class AppModule {}
Place ConfigModule first in your imports. This ensures other modules can access configuration immediately.
The envFilePath setting loads .env.production on your VPS (since NODE_ENV=production in PM2 config). You can specify multiple paths as a fallback:
envFilePath: [`.env.${process.env.NODE_ENV}`, '.env']
Environment files per stage
Create .env.production on your VPS (never commit to Git):
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/nestjs_db
JWT_SECRET=your-secret-key
LOG_LEVEL=warn
REDIS_URL=redis://localhost:6379
Set restrictive file permissions:
chmod 600 /var/www/my-nestjs-app/.env.production
Only your app user can read it. This protects secrets from other system users.
Validation with environment variables
Validate configuration at startup to catch missing variables early:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, IsString, validateSync } from 'class-validator';
class EnvironmentVariables {
@IsEnum(['development', 'production'])
NODE_ENV: string;
@IsNumber()
PORT: number;
@IsString()
DATABASE_URL: string;
@IsString()
JWT_SECRET: string;
}
function validate(config: Record) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false
});
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV}`,
validate
})
]
})
export class AppModule {}
Now if DATABASE_URL or JWT_SECRET is missing, your app fails immediately with a clear error, not later when a query tries to execute.
Setting up Nginx as a reverse proxy
Nginx listens on ports 80 (HTTP) and 443 (HTTPS), forwarding requests to your NestJS app on localhost:3000.
Basic HTTP proxy configuration
Create /etc/nginx/sites-available/my-nestjs-app:
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location / {
proxy_pass http://localhost:3000;
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;
proxy_cache_bypass $http_upgrade;
}
}
The headers are critical:
X-Real-IP passes the client's true IP to your app. Without it, req.ip is Nginx's IP (localhost).
X-Forwarded-For contains a list of proxies the request passed through.
X-Forwarded-Proto tells your app whether the original request was HTTP or HTTPS.
Upgrade and Connection headers enable WebSocket proxying.
Enable the configuration:
sudo ln -s /etc/nginx/sites-available/my-nestjs-app /etc/nginx/sites-enabled/
sudo nginx -t # Test syntax
sudo systemctl restart nginx
SSL with Certbot
Your VPS includes Certbot. Obtain a free Let's Encrypt certificate:
sudo certbot certonly --nginx -d your-domain.com -d www.your-domain.com
Certbot asks you to verify domain ownership. Choose email verification if DNS records aren't updated yet.
Update your Nginx config to use HTTPS and redirect HTTP to HTTPS:
server {
listen 80;
server_name your-domain.com www.your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com www.your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://localhost:3000;
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;
proxy_cache_bypass $http_upgrade;
}
}
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Visit https://your-domain.com in your browser. You should see your NestJS app. The address bar shows a green lock (secure).
Certbot automatically renews certificates 30 days before expiry via a cron job. No manual action needed.
WebSocket support in proxy
The Upgrade and Connection headers in the proxy config enable WebSocket proxying. Your NestJS app can use WebSockets out of the box. No additional Nginx configuration needed.
Health checks with @nestjs/terminus
Production apps need liveness and readiness checks. Kubernetes, Docker, and load balancers periodically ping health endpoints to verify your app is responsive.
Installing @nestjs/terminus
npm install @nestjs/terminus
Generate a health module:
nest generate module health
nest generate controller health
Implementing HTTP health indicator
Update src/health/health.controller.ts:
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
HttpHealthIndicator
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator
) {}
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([
() => this.http.pingCheck('nestjs', 'http://localhost:3000/health/live')
]);
}
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready')
]);
}
}
This creates /health/live and /health/ready endpoints. Liveness checks if the app is running. Readiness checks if it's ready to serve traffic (databases connected, caches warm, etc.).
Database health indicator
Add database checks:
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
HttpHealthIndicator,
TypeOrmHealthIndicator
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private db: TypeOrmHealthIndicator
) {}
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready'),
() => this.db.pingCheck('database')
]);
}
}
Now /health/ready checks both HTTP and database connectivity. If the database is down, readiness fails, and your orchestration system marks the app unavailable.
Custom health checks
Add custom checks for dependencies like Redis:
import { Injectable } from '@nestjs/common';
import {
HealthIndicator,
HealthIndicatorResult,
HealthCheckError
} from '@nestjs/terminus';
import { InjectRedis } from '@nestjs-modules/redis';
import { Redis } from 'ioredis';
@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
constructor(@InjectRedis() private redis: Redis) {
super();
}
async isHealthy(): Promise {
try {
await this.redis.ping();
return this.getStatus('redis', true);
} catch (error) {
throw new HealthCheckError('Redis failed', this.getStatus('redis', false));
}
}
}
Then use it in your controller:
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.http.pingCheck('nestjs', 'http://localhost:3000/health/ready'),
() => this.db.pingCheck('database'),
() => this.redis.isHealthy()
]);
}
Handling updates and zero-downtime deployments
When you push new code, you want zero downtime.
SSH to your VPS and pull the latest code:
cd /var/www/my-nestjs-app
git pull origin main
npm install
npm run build
pm2 reload my-nestjs-app
pm2 reload performs a graceful, rolling restart. It restarts one PM2 worker at a time. While one worker restarts, others handle traffic. Users see no downtime.
For multi-environment deployments (staging, production), use git tags or branches:
git pull origin main
git checkout v1.2.3 # Deploy a specific version
npm install
npm run build
pm2 reload my-nestjs-app
Automate this with CI/CD. A GitHub Actions workflow can push to your VPS and run teh deployment commands automatically when you push to the main branch.
NestJS production optimizations
Fastify adapter instead of Express
NestJS defaults to Express, but Fastify is faster and uses less memory. Switch in main.ts:
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(
AppModule,
new FastifyAdapter({ logger: true })
);
await app.listen(3000, '0.0.0.0');
}
bootstrap();
Fastify also handles graceful shutdown more elegantly, as shown in cluster mode docs.
Compression middleware
Enable gzip compression:
import { Module } from '@nestjs/common';
import { APP_MIDDLEWARE } from '@nestjs/core';
import { CompressionMiddleware } from '@nestjs/common';
@Module({
providers: [
{
provide: APP_MIDDLEWARE,
useClass: CompressionMiddleware
}
]
})
export class AppModule {}
Or with Express adapter:
import compression from 'compression';
app.use(compression());
Compression reduces response sizes by 60-80%, speeding up browsers and mobile clients.
CORS configuration
Configure CORS appropriately for production:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['https://yourdomain.com', 'https://www.yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true
});
await app.listen(3000);
}
bootstrap();
Whitelist your domain instead of using * (all origins), which is a security risk.
Logging for production
NestJS comes with a built-in Logger. Use it instead of console.log:
import { Logger } from '@nestjs/common';
export class UsersService {
private logger = new Logger(UsersService.name);
getUsers() {
this.logger.log('Fetching users');
return [...];
}
handleError(error: Error) {
this.logger.error(`Error: ${error.message}`, error.stack);
}
}
The Logger prefixes logs with the class name and timestamp, making debugging easier.
For advanced logging (sending logs to external services like Datadog, CloudWatch), consider custom transports with Winston or Pino.
Database migrations in deployment
If you use TypeORM with migrations, run them before starting your app:
npm run typeorm migration:run
pm2 start ecosystem.config.js
Add a migration script to package.json:
"scripts": {
"typeorm": "typeorm-ts-node-esm",
"migration:run": "npm run typeorm migration:run",
"migration:create": "npm run typeorm migration:create"
}
For zero-downtime migrations, use feature flags or versioned APIs to support both old and new schema during the transition.
Troubleshooting production deployment
502 bad gateway errors
Nginx shows 502 when it can't reach your NestJS app. Check if the app is running:
pm2 list
pm2 logs my-nestjs-app --err
Common causes: app crashed on startup, missing environment variables, or port mismatch (app listening on 3001 instead of 3000).
Test locally:
curl http://localhost:3000/health
If this fails, your app isn't listening on 3000.
TypeScript errors in production
If you see Cannot find module errors, you're running TypeScript in production. Always compile first:
npm run build
Never run ts-node or tsx in production. It's slow and error-prone.
Module not found
This usually means devDependencies are missing. Ensure @nestjs/cli is in dependencies, not devDependencies. Or run npm install without --production flag.
SSL certificate issues
If HTTPS fails, check certificate paths in Nginx config match your domain. List active certificates:
sudo certbot certificates
If a certificate is missing, renew it:
sudo certbot renew --force-renewal -d your-domain.com

