Typed, validated configuration: Never let a missing variable wake you at 3 AM
I've been woken up more times than I'd like to admit by alerts saying "undefined is not a function" or "Cannot read property of null." Half the time, it wasn't a bug in the code—it was a missing environment variable that made it through to production. Typed, validated configuration is the pattern that stops that from happening. Instead of crashing randomly when your app needs a database URL it never found, you detect the problem before the first HTTP request arrives and the app refuses to start. This article walks you through building a rock-solid config layer in NestJS using schemas, injection, and namespacing so your production deploys are predictable and your tests stay isolated.
Why configuration matters in a production system
Configuration is often the forgotten piece. Developers focus on business logic, database queries, API responses—but the moment a required variable is missing or malformed, everything breaks. The worst part: if you discover this bug in production, you've already deployed, and users are already affected.
Consider a real scenario. You're running a ride-hailing backend with separate services for payments, geolocation, and notifications. Each service needs API keys, database URLs, and feature flags. One developer forgets to set PAYMENT_API_KEY in staging. The service starts fine; the code that needs that key doesn't run until a user actually books a ride at 3 AM. Now your on-call engineer is debugging a 500 error instead of sleeping.
NestJS's ConfigModule with validation fixes this by:
- Loading all config once at startup
- Validating it against a schema immediately
- Refusing to start if validation fails
- Making config injectable and testable instead of scattered
process.envcalls
ConfigModule globally: Load and inject everywhere
The first step is telling NestJS to load your .env file once and make it available everywhere. You do this in your root module.
// src/config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'production'
? '.env.prod'
: '.env',
}),
],
})
export class AppConfigModule {}
Then import it in your main app module:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppConfigModule } from './config/config.module';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [
AppConfigModule,
DatabaseModule,
// ... other modules
],
})
export class AppModule {}
Now, anywhere in your application, you can inject ConfigService and read values:
// src/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
@Injectable()
export class DatabaseService {
private pool: Pool;
constructor(private configService: ConfigService) {
this.pool = new Pool({
host: this.configService.get('DB_HOST'),
port: this.configService.getOrThrow('DB_PORT'),
database: this.configService.get('DB_NAME'),
user: this.configService.get('DB_USER'),
password: this.configService.get('DB_PASSWORD'),
});
}
async query(sql: string, params: any[]) {
return this.pool.query(sql, params);
}
}
Notice I used getOrThrow('DB_PORT') for required values. That method throws immediately if the key doesn't exist, which is better than silently falling back to undefined. But we can do even better with schemas.
Validate on boot: Fail hard, fail fast
Hard-coding getOrThrow calls is fragile. If you forget one, the code still starts and fails later. Instead, define a schema that validates all your config at once when the app boots. I use Zod, but Joi or class-validator work too.
First, install Zod:
npm install zod
Then define your schema:
// src/config/env.schema.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
APP_PORT: z.coerce.number().min(1).max(65535),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().min(1).max(65535),
DB_NAME: z.string().min(1),
DB_USER: z.string().min(1),
DB_PASSWORD: z.string().min(1),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PAYMENT_API_KEY: z.string().min(1),
PAYMENT_API_URL: z.string().url(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer;
Now wire the validation into ConfigModule:
// src/config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { envSchema } from './env.schema';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'production'
? '.env.prod'
: '.env',
validate: (env) => {
const parsed = envSchema.safeParse(env);
if (!parsed.success) {
const errors = parsed.error.errors
.map(e => `${e.path.join('.')}: ${e.message}`)
.join('\n');
throw new Error(
`Environment validation failed:\n${errors}`
);
}
return parsed.data;
},
}),
],
})
export class AppConfigModule {}
Now, if you're missing DB_HOST, or if DB_PORT is not a number, the entire application refuses to start. No guessing. No silent failures. You run npm run start, and immediately you get a clear error message telling you exactly what's wrong.
Namespaced config: Group and type related settings
A flat list of environment variables gets messy fast. When you have 20+ variables, it's hard to know which ones belong together. NestJS lets you group them with registerAs, which keeps everything typed and organized.
Create config factories for each subsystem:
// src/config/database.config.ts
import { registerAs } from '@nestjs/config';
export interface DatabaseConfig {
host: string;
port: number;
name: string;
user: string;
password: string;
}
export default registerAs('database', (): DatabaseConfig => ({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
name: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
}));
// src/config/payment.config.ts
import { registerAs } from '@nestjs/config';
export interface PaymentConfig {
apiKey: string;
apiUrl: string;
timeout: number;
}
export default registerAs('payment', (): PaymentConfig => ({
apiKey: process.env.PAYMENT_API_KEY,
apiUrl: process.env.PAYMENT_API_URL,
timeout: parseInt(process.env.PAYMENT_TIMEOUT, 10) || 30000,
}));
// src/config/redis.config.ts
import { registerAs } from '@nestjs/config';
export interface RedisConfig {
url: string;
}
export default registerAs('redis', (): RedisConfig => ({
url: process.env.REDIS_URL,
}));
Register them in your config module:
// src/config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import databaseConfig from './database.config';
import paymentConfig from './payment.config';
import redisConfig from './redis.config';
import { envSchema } from './env.schema';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.NODE_ENV === 'production'
? '.env.prod'
: '.env',
load: [databaseConfig, paymentConfig, redisConfig],
validate: (env) => {
const parsed = envSchema.safeParse(env);
if (!parsed.success) {
const errors = parsed.error.errors
.map(e => `${e.path.join('.')}: ${e.message}`)
.join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
return parsed.data;
},
}),
],
})
export class AppConfigModule {}
Now, in services, inject the specific config you need:
// src/payment/payment.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PaymentConfig } from '../config/payment.config';
@Injectable()
export class PaymentService {
private config: PaymentConfig;
constructor(configService: ConfigService) {
this.config = configService.get('payment');
}
async processPayment(amount: number, cardToken: string) {
const response = await fetch(this.config.apiUrl + '/charge', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({ amount, cardToken }),
});
return response.json();
}
}
The benefit: your code is typed. If you misspell a property, TypeScript catches it at compile time. And your tests can override just the payment config without touching environment variables.
No process.env in services: Inject config, keep tests clean
The cardinal rule: never call process.env.SOMETHING directly in a service. It makes testing impossible and couples your code to the runtime environment. Always inject ConfigService instead.
Bad:
// src/notification/notification.service.ts
@Injectable()
export class NotificationService {
async sendEmail(to: string, subject: string, body: string) {
const apiKey = process.env.SENDGRID_API_KEY; // ❌ Direct access
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ to, subject, body }),
});
return response.json();
}
}
Good:
// src/notification/notification.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class NotificationService {
constructor(private configService: ConfigService) {}
async sendEmail(to: string, subject: string, body: string) {
const apiKey = this.configService.get('SENDGRID_API_KEY'); // ✅ Injected
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ to, subject, body }),
});
return response.json();
}
}
Now, in your test, you can mock ConfigService:
// src/notification/notification.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
let mockConfigService: Partial;
beforeEach(async () => {
mockConfigService = {
get: jest.fn((key: string) => {
const mockConfig = {
SENDGRID_API_KEY: 'test-key-12345',
};
return mockConfig[key];
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationService,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = module.get(NotificationService);
});
it('should send email with injected API key', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ success: true }),
} as Response)
);
await service.sendEmail('user@example.com', 'Test', 'Hello');
expect(mockConfigService.get).toHaveBeenCalledWith('SENDGRID_API_KEY');
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer test-key-12345',
}),
})
);
});
});
No environment variables, no pollution of process.env, no flaky tests. You control exactly what config values your service sees.
Common mistakes
- Forgetting
isGlobal: true: Without it, you must importConfigModulein every module that needs it. One global import is cleaner and matches most real projects. - Validating only required fields: Make your schema strict. Mark optional fields with
.optional()explicitly. This way, you catch typos in variable names at startup, not when the code runs. - Parsing environment variables as strings:
process.env.DB_PORTis always a string. Usez.coerce.number()in your schema to parse it automatically. Forgetting this is a common source of "port is not a number" bugs. - Mixing raw
process.envwithConfigService: Pick one. InjectingConfigServiceis the right choice because it's testable. If you mix both, you'll have a hard time understanding which values come from where. - Not validating in tests: If your validation only runs in
forRoot, your test setup might skip it. Make sure your test modules also validate config or you'll catch bugs in production, not in CI.
Key takeaways
- Use
ConfigModule.forRoot({ isGlobal: true })to load and inject config globally instead of readingprocess.enveverywhere. - Define a Zod schema and pass it to the
validateoption so missing or malformed variables fail at startup, not at 3 AM. - Use
registerAsto namespace related settings (database, payment, Redis) and keep your code typed and organized. - Always inject
ConfigServiceinto services instead of accessingprocess.envdirectly. This makes tests clean and services testable without mocking the environment. - In tests, mock
ConfigServiceto control what config values your services see. No environment variable pollution.






Comments
Loading comments…