When you're running NestJS in production, your database passwords, API keys, and third-party credentials are no longer safely tucked away on your laptop. Secrets and configuration in production demand a completely different strategy than your local development workflow. In this post, I'll walk you through the real-world approach I use across platforms at scale—from keeping secrets out of your repository to validating them before a single request hits your application.
Never in the repo — .env files stay local and in .gitignore
The first rule is absolute: never commit secrets to your repository. I've seen it happen more times than I'd like—a developer commits a `.env` file with a database password, it sits in git history forever, and even if deleted later, it remains in every clone and fork. This isn't paranoia; it's a baseline security hygiene that prevents accidental exposure.
Your local development flows through `.env` files. You create one, load it with @nestjs/config, and everything works. But that same file must never touch your remote repository.
Here's what I do:
- Create
.envwith all your local secrets and database URLs. - Add
.envto.gitignoreimmediately. - Commit a
.env.examplefile that shows the shape of required variables without actual values.
Example .env.example:
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=change_me
DATABASE_NAME=mydb
JWT_SECRET=your_jwt_secret_here
REDIS_URL=redis://localhost:6379
API_KEY_STRIPE=sk_test_...
API_KEY_TWILIO=your_twilio_key
NODE_ENV=development
Notice that every variable has a placeholder or example value. New team members clone the repo, copy .env.example to .env, and fill in their local values. This approach keeps the repository clean while documenting what's needed.
In your main main.ts, validate that the file exists before bootstrapping:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// Crash early if .env is missing in development
if (process.env.NODE_ENV !== 'production' && !require('fs').existsSync('.env')) {
throw new Error('.env file not found. Copy .env.example and fill in your values.');
}
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
Platform secrets — Inject via Kubernetes secrets, ECS task definitions or CI variables
Once you leave your laptop, `.env` files cease to exist. Production environments use platform-native secret injection mechanisms. I've deployed on Kubernetes, AWS ECS, and CI/CD pipelines, and each has a different way of passing secrets to your application.
Kubernetes Secrets: You create a secret object, mount it as environment variables or files, and your Pod reads them at runtime.
apiVersion: v1
kind: Secret
metadata:
name: nestjs-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:pass@db-host:5432/prod_db"
JWT_SECRET: "your_production_jwt_secret"
REDIS_URL: "redis://redis-host:6379"
---
apiVersion: v1
kind: Pod
metadata:
name: nestjs-app
spec:
containers:
- name: app
image: myapp:latest
envFrom:
- secretRef:
name: nestjs-secrets
Your NestJS application reads these as normal environment variables. process.env.DATABASE_URL will contain the actual database connection string injected by Kubernetes at runtime.
AWS ECS Task Definition: You define secrets in the task definition, and ECS injects them as environment variables or pulls them from AWS Secrets Manager on container startup.
{
"name": "nestjs-app",
"image": "my-repo/nestjs-app:latest",
"secrets": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:region:account:secret:prod/db/password"
},
{
"name": "JWT_SECRET",
"valueFrom": "arn:aws:secretsmanager:region:account:secret:prod/jwt/secret"
}
]
}
In your NestJS config, you reference them as environment variables:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'production' ? undefined : '.env',
}),
],
})
export class AppModule {}
CI/CD Environment Variables: GitHub Actions, GitLab CI, and similar platforms let you define secrets in their web interface. During a deployment job, these are exposed as environment variables to your build and deployment scripts.
name: Deploy to production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy
run: |
docker build -t myapp:${{ github.sha }} .
docker run -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
-e JWT_SECRET=${{ secrets.JWT_SECRET }} \
myapp:${{ github.sha }}
The pattern across all platforms is identical: define secrets in the platform, expose them as environment variables, and read them in your application code.
Secret managers — AWS Secrets Manager or Vault with rotation for database credentials
For credentials that change frequently or need audit trails, dedicated secret managers like AWS Secrets Manager or HashiCorp Vault offer automatic rotation, encryption at rest, and detailed access logs.
Instead of injecting a static database password into your container, you configure your application to fetch it from the secret manager at startup. If the password rotates, your application gets the new value on next restart.
Using AWS Secrets Manager in NestJS:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
useFactory: async () => {
// In production, fetch from Secrets Manager
if (process.env.NODE_ENV === 'production') {
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
try {
const response = await client.send(
new GetSecretValueCommand({ SecretId: 'prod/nestjs/config' })
);
const secret = JSON.parse(response.SecretString || '{}');
return {
DATABASE_URL: secret.database_url,
JWT_SECRET: secret.jwt_secret,
REDIS_URL: secret.redis_url,
};
} catch (error) {
throw new Error(`Failed to load secrets from AWS Secrets Manager: ${error.message}`);
}
}
// In development, use .env
return {};
},
}),
],
})
export class AppModule {}
This approach gives you several benefits:
- Automatic rotation: Rotate your database password in the secret manager, and NestJS picks up the new value on restart.
- Audit trails: Every access to the secret is logged, so you know who accessed what and when.
- Fine-grained permissions: Use IAM policies to restrict which services can access which secrets.
- Encryption: Secrets are encrypted at rest using AWS KMS.
Using HashiCorp Vault: If you're already running Vault, the pattern is similar but uses Vault's API client.
import * as vault from 'node-vault';
const client = vault({
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN,
});
const response = await client.read('secret/data/prod/nestjs');
const { database_url, jwt_secret } = response.data.data;
Vault shines in multi-cloud or self-hosted setups where you want a single source of truth for all secrets across your entire infrastructure.
Validate on boot — Missing secret? Crash immediately with a clear message
Here's a principle I live by: if a required secret is missing, the application should crash immediately with a clear error message, not halfway through a request. This prevents subtle bugs where a feature silently fails because a credential wasn't injected.
Use NestJS's built-in validation with class-validator to enforce required configuration on startup:
import { IsString, IsNotEmpty, IsURL, validateSync } from 'class-validator';
import { plainToClass } from 'class-transformer';
export class EnvironmentVariables {
@IsNotEmpty({ message: 'DATABASE_URL is required' })
@IsString()
DATABASE_URL: string;
@IsNotEmpty({ message: 'JWT_SECRET is required' })
@IsString()
JWT_SECRET: string;
@IsNotEmpty({ message: 'REDIS_URL is required' })
@IsURL()
REDIS_URL: string;
@IsNotEmpty({ message: 'NODE_ENV is required' })
@IsString()
NODE_ENV: string;
}
export function validateConfig(config: Record) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
const errorMessages = errors
.map((error) => Object.values(error.constraints || {}).join(', '))
.join('\n');
throw new Error(`Configuration validation failed:\n${errorMessages}`);
}
return validatedConfig;
}
Now integrate it into your ConfigModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'production' ? undefined : '.env',
validate: validateConfig,
}),
],
})
export class AppModule {}
When you start the application, if any required variable is missing, you'll see:
Configuration validation failed:
DATABASE_URL is required
JWT_SECRET is required
REDIS_URL is required
The application crashes immediately. Your deployment orchestrator (Kubernetes, ECS, systemd) will see the failure, log it, and prevent the bad container from serving traffic. This is exactly what you want—fail loudly and early.
Common mistakes
Mistake 1: Committing `.env` to git. Use a pre-commit hook to catch this:
#!/bin/bash
# .git/hooks/pre-commit
if git diff --cached --name-only | grep -q "^\\.env$"; then
echo "Error: .env file cannot be committed."
exit 1
fi
Mistake 2: No validation on boot. A missing secret buried deep in your codebase might not surface until a user triggers that feature. Validate everything upfront.
Mistake 3: Logging secrets. Never log the value of secrets, even in debug mode. Instead, log only the presence or absence:
const hasSecret = !!process.env.DATABASE_PASSWORD;
console.log(`Database password configured: ${hasSecret}`);
// ✅ Good
console.log(`Database password: ${process.env.DATABASE_PASSWORD}`);
// ❌ Bad — logs the actual password
Mistake 4: Mixing local and production configurations. Keep them entirely separate. Use `NODE_ENV` to branch logic, not file paths or conditionals scattered throughout your code.
Key takeaways
- Commit `.env.example` with placeholder values; add `.env` to `.gitignore` and never commit actual secrets.
- Use platform-native secret injection—Kubernetes secrets, ECS task definitions, or CI/CD environment variables—to pass credentials to your production containers.
- For frequently-rotated or audit-sensitive credentials, integrate with AWS Secrets Manager or Vault at application startup.
- Validate all required configuration before your application starts serving requests; crash immediately with a clear error message if anything is missing.
- Never log or expose secret values, even in debug logs; only log their presence.
- Keep development and production configuration logic separate; use `NODE_ENV` to branch behavior cleanly.






Comments
Loading comments…