By day 11, you've built controllers, services, and databases. Now comes the harder part: making your code work the same way in development, staging, and production without littering your codebase with if (env === 'prod') checks. Environment-specific configuration is how you separate deployment concerns from business logic, letting the same compiled code behave differently depending on where it runs.
One .env per environment, never committed
The most common mistake I see is committing .env files to git. Even with a "don't do this" comment in the README, someone will, and three months later you're rotating secrets. I watched a team lose production API keys because a junior dev committed .env thinking it was a template. It wasn't. Follow this pattern instead:
- Create
.env.examplein your repository with placeholder values and descriptions - Create
.env,.env.staging,.env.productionlocally or on your deployment platform—never in git - Add
.env*(or at least.env,.env.local) to.gitignore - Document which variables each environment needs and what happens if they're missing
Your .env.example might look like this:
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-here
LOG_LEVEL=debug
API_RATE_LIMIT=100
THIRD_PARTY_API_KEY=sk_test_xxxxx
MAIL_ENABLED=false
MAIL_PROVIDER=console
CACHE_TTL_SECONDS=3600
Then locally, you'd have a .env.development with real test values. On your deployment platform (AWS Secrets Manager, GitHub Secrets, Vercel, Railway, your container orchestrator), you'd set production versions. NestJS with @nestjs/config loads these automatically based on NODE_ENV. When you deploy to staging, your CI/CD pipeline injects .env.staging values. When you deploy to production, it injects production secrets. Same code, different behavior.
Here's the gitignore rule:
# .gitignore
.env
.env.local
.env.*.local
.env.production
.env.staging
.env.development
But .env.example stays in git—it's your documentation contract.
Feature differences belong in config, not code
I used to write code like this in a CRM system I was building:
async sendEmail(user: User) {
if (process.env.NODE_ENV === 'production') {
await this.emailService.send(user.email, 'Welcome!');
} else {
console.log('Would send email to:', user.email);
}
}
This spreads environment logic everywhere. You end up with environment checks in your service layer, in middleware, in job processors. It's a maintenance nightmare—if you add a new environment, you have to find every if (env === ...) check. Instead, push it to configuration:
// config/mail.config.ts
export default () => ({
mail: {
enabled: process.env.MAIL_ENABLED === 'true',
from: process.env.MAIL_FROM || 'noreply@localhost',
provider: process.env.MAIL_PROVIDER || 'console',
retryAttempts: parseInt(process.env.MAIL_RETRY_ATTEMPTS || '3'),
retryDelayMs: parseInt(process.env.MAIL_RETRY_DELAY_MS || '5000'),
},
});
Then in your .env.development:
MAIL_ENABLED=false
MAIL_PROVIDER=console
MAIL_RETRY_ATTEMPTS=1
MAIL_RETRY_DELAY_MS=100
And in .env.production (stored on your platform, never committed):
MAIL_ENABLED=true
MAIL_FROM=hello@company.com
MAIL_PROVIDER=aws-ses
MAIL_RETRY_ATTEMPTS=5
MAIL_RETRY_DELAY_MS=10000
Now your service stays clean and testable:
import { Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UserService {
constructor(
private config: ConfigService,
private emailService: EmailService,
private logger: Logger,
) {}
async sendEmail(user: User) {
if (!this.config.get('mail.enabled')) {
this.logger.debug('Mail disabled, skipping email to ' + user.email);
return;
}
const retries = this.config.get('mail.retryAttempts');
const delayMs = this.config.get('mail.retryDelayMs');
await this.emailService.send(user.email, 'Welcome!', {
retries,
retryDelayMs: delayMs,
});
}
}
The business logic doesn't know or care about environments. It just respects configuration. Testing becomes easy too—you can mock different config values without stubbing environment variables across your test suite.
Defaults that are safe for production
When you set defaults, choose the safe option. If a setting is missing, what's the least damaging behavior? This saved me during a production incident once when someone forgot to set an env var—the system defaulted to safe behavior instead of crashing or going haywire.
For example:
- Rate limiting: Default to strict (e.g., 10 req/min) rather than permissive. If you forget to set it, you'd rather reject some valid traffic than expose yourself to abuse.
- Logging: Default to minimal (warn/error) not verbose. Extra logs slow you down more than missing debug logs.
- Features: Default to disabled unless explicitly enabled. A missing flag should mean "don't do this thing" not "do it".
- Timeouts: Default to shorter (fail fast) not longer. Hanging requests cause cascading failures.
- Cache TTL: Default to short (5 minutes) not long. Stale cache is better than missing fresh data.
Here's a real example from a ride-hailing service I built where defaulting safely mattered:
// config/matching.config.ts
export default () => ({
matching: {
maxSearchRadiusKm: parseInt(process.env.MAX_SEARCH_RADIUS_KM || '5'),
searchTimeoutMs: parseInt(process.env.SEARCH_TIMEOUT_MS || '8000'),
enableMatchingV2: process.env.ENABLE_MATCHING_V2 === 'true',
maxConcurrentSearches: parseInt(process.env.MAX_CONCURRENT_SEARCHES || '50'),
minMatchScoreThreshold: parseFloat(process.env.MIN_MATCH_SCORE || '0.6'),
},
});
Every default is conservative. A missing env var won't accidentally allow thousands of concurrent matches (which would overwhelm your database), won't timeout after 30 seconds (causing customer complaints), and won't match low-quality driver candidates. In staging and production, we override with tested values:
// .env.production (stored on AWS Secrets Manager, not in git)
MAX_SEARCH_RADIUS_KM=15
SEARCH_TIMEOUT_MS=12000
ENABLE_MATCHING_V2=true
MAX_CONCURRENT_SEARCHES=500
MIN_MATCH_SCORE=0.5
The difference between development and production is meaningful—we can search further, tolerate longer timeouts, enable the new algorithm, and accept more concurrent operations. But if production somehow runs with development defaults (say, a deployment script breaks), it won't cause data loss or system crashes. It'll just be slightly slower.
Secrets come from the platform, not files
Even with .env files in .gitignore, local development secrets leak. They live on your laptop, in backups, in your shell history, in your IDE's recent-files cache, in git stashes you forgot about. Production secrets belong on your deployment platform, behind access controls and audit logs.
Here's how I structure it now:
- Local development:
.env.localwith test values (expired test API keys, localhost URLs, fake secrets). These can be checked into a private team wiki or shared in a secure note—they're not production secrets. - Staging: Secrets stored in your platform (Vercel, Railway, AWS Secrets Manager, GitHub Secrets). Set through the dashboard or CLI. Rotated annually.
- Production: Separate, more restricted secrets in the platform. Only certain team members can view. Rotated quarterly or on breach.
In NestJS, this works seamlessly. ConfigService reads from env vars regardless of source:
// config/database.config.ts
export default () => ({
database: {
url: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'true',
maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || '20'),
},
});
// config/auth.config.ts
export default () => ({
auth: {
jwtSecret: process.env.JWT_SECRET,
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET,
},
});
// In any service
@Injectable()
export class AuthService {
constructor(private config: ConfigService) {}
sign(payload: any) {
const secret = this.config.get('auth.jwtSecret');
if (!secret) {
throw new Error('JWT_SECRET not configured');
}
return jwt.sign(payload, secret, {
expiresIn: this.config.get('auth.jwtExpiresIn'),
});
}
}
ConfigService doesn't care if DATABASE_URL came from your .env.local file or from Vercel's environment variables UI. It's just an env var. Your deployment pipeline simply injects secrets when the container starts. On Vercel, Railway, AWS ECS, or Lambda, you set env vars through the dashboard or CLI. Your code is identical everywhere—only the environment changes. This means you can run the exact same Docker image in staging and production. No recompilation, no rebuilds. Just different secrets.
Config validation and type safety
Reading env vars as strings is error-prone. Add validation:
// config/validation.ts
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, IsString, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Staging = 'staging',
Production = 'production',
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment;
@IsString()
DATABASE_URL: string;
@IsString()
JWT_SECRET: string;
@IsNumber()
API_RATE_LIMIT: number;
@IsString()
MAIL_PROVIDER: string;
}
export function validateConfig(config: Record) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(`Config validation failed: ${errors.toString()}`);
}
return validatedConfig;
}
Then in your main.ts, validate before starting the app:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { validateConfig } from './config/validation';
async function bootstrap() {
validateConfig(process.env);
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap().catch((err) => {
console.error('Failed to start app:', err.message);
process.exit(1);
});
Now if you deploy without setting JWT_SECRET, the app fails on startup with a clear error message instead of crashing mysteriously later.
Environment-specific behaviors: a real example
In a SaaS trading platform, I had different behavior for dev, staging, and production:
// config/trading.config.ts
export default () => ({
trading: {
// Development: instant execution for testing
executionDelayMs: process.env.EXECUTION_DELAY_MS || '0',
// Staging: realistic but safe defaults
maxOrderSize: parseInt(process.env.MAX_ORDER_SIZE || '1000'),
enableLiveFeeds: process.env.ENABLE_LIVE_FEEDS === 'true',
// Production: strict controls
orderSlippageTolerancePercent: parseFloat(
process.env.ORDER_SLIPPAGE_TOLERANCE_PERCENT || '0.1'
),
maxDailyLossPercent: parseFloat(
process.env.MAX_DAILY_LOSS_PERCENT || '2'
),
requireHumanApprovalOverAmount: parseInt(
process.env.REQUIRE_APPROVAL_OVER_AMOUNT || '100000'
),
},
});
Then in my trading service:
async executeOrder(order: Order) {
const slippage = this.config.get('trading.orderSlippageTolerancePercent');
if (order.amount > this.config.get('trading.requireHumanApprovalOverAmount')) {
// In production, big orders need approval
return this.queueForApproval(order);
}
// Check daily loss limit
const todaysLoss = await this.getTodaysRealizedLoss();
const maxDailyLoss = this.config.get('trading.maxDailyLossPercent');
if (todaysLoss > maxDailyLoss) {
return this.rejectOrder(order, 'Daily loss limit exceeded');
}
return this.executeWithSlippage(order, slippage);
}
In .env.development: tiny limits, no approval needed, instant execution. In .env.production: strict limits, approval for large orders, tight slippage tolerance. Same code, wildly different behavior.
Common mistakes I've seen and fixed
- Committing .env with real secrets: One committed secret is enough to compromise production. I've seen teams lose AWS credentials this way. Use .gitignore aggressively and rotate immediately if it happens. GitHub scans for committed secrets now, but don't count on it.
- Duplicating config logic: Don't read env vars in 10 different places. Create a central config file per domain (database, mail, auth, cache, etc.) and inject it. When you need to change how DATABASE_URL is parsed, you change one place.
- Choosing unsafe defaults: If a config value is missing, what's the worst that can happen? Default to the safe behavior. I once inherited code where a missing STRIPE_WEBHOOK_SECRET defaulted to accepting all webhooks. Bad.
- Not documenting required env vars: .env.example is your contract. Keep it up to date so the next developer (or you in 6 months) knows what's required and why.
- Mixing secrets and settings: Database URL and JWT secret are secrets (rotate on breaches, restrict access). Feature flags and rate limits are settings (change freely, visible in logs). Treat them differently. Secrets should never be logged.
- Type coercion bugs:
process.env.API_RATE_LIMITis a string. If you compare it with>or<, you're comparing strings. Always parse with parseInt or parseFloat. Add validation at startup.
Key takeaways
- Never commit
.envfiles. Use.env.exampleto document required variables and safe defaults. It stays in git; actual values never do. - Push environment-specific logic into config objects, not scattered throughout your code. Your services should be unaware of which environment they're in. Inject config, not env vars.
- Default to the safe, restrictive behavior. A missing env var should fail safely, not accidentally enable dangerous defaults or crash mysteriously later.
- Store secrets on your deployment platform (Vercel, AWS Secrets Manager, GitHub Secrets). Local
.envfiles are for development convenience with non-sensitive test values only. Add validation at startup to catch missing secrets early. - Create one config file per domain (database, mail, auth, cache, trading, etc.) so you have a single source of truth for environment-specific behavior. Inject these into services, never read env vars directly in business logic.
- Add config validation using class-validator or similar. Catch missing or invalid env vars on startup, not during a request at 3 AM in production.






Comments
Loading comments…