Validating Configuration at Boot
Check every required setting while the app starts, so a missing variable is one startup error and not a 500 later.
ConfigModule.forRoot({ validationSchema }) | validate: (config: Record<string, any>) => Record<string, any>A missing DATABASE_URL can stay hidden until the first request that touches the database. Validating configuration moves that failure to startup: one loud error in the deploy log instead of a 500 at an unpredictable time.
Two ways in
validationSchema accepts any schema implementing the Standard Schema specification — Zod, Valibot, ArkType, or Joi 18 and later. Before @nestjs/config 12 this option was Joi-specific and library settings sat directly on validationOptions; they now go under validationOptions.libraryOptions.
validate accepts a synchronous function, (config: Record<string, any>) => Record<string, any>. Throwing stops the application from bootstrapping; whatever you return becomes the validated configuration.
When it runs
Inside forRoot(), during bootstrap: after the .env file is parsed and merged with process.env, and before any provider is constructed or the server listens. No guard, pipe or handler ever sees a half-configured app.
Validated values are stored separately and consulted before raw process.env, so coercion sticks. With PORT: z.coerce.number(), configService.get('PORT') returns the number 3000, not the string.
What trips people up
Failures are reported as path: message, one per line, wrapped in Config validation error:. Object schemas drop keys they do not declare, but @nestjs/config 12 merges the undeclared ones back so unrelated variables stay readable. validatePredefined defaults to true, which includes variables set before the module was imported, such as PORT=3000 node main.js; the older ignoreEnvVars option is deprecated in favour of it.
Example
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// In @nestjs/config 12 this takes any Standard Schema:
// Zod, Valibot, ArkType, or Joi 18+.
validationSchema: z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
// Env values are strings, so coerce the ones that are not.
// The coerced number is what ConfigService.get('PORT') returns.
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().min(1),
// Absent is fine; present but empty is not.
REDIS_URL: z.string().min(1).optional(),
}),
}),
],
})
export class AppModule {}
When to use it
- Make a deploy fail in the first second when a secret was not wired into the environment.
- Turn PORT and feature flags into real numbers and booleans once, at boot, instead of parsing them at each call site.
- Document the full set of variables the service needs in one schema file a new teammate can read.
- Catch a typo like DATABSE_URL in CI rather than in the first request that opens a connection.
More examples
A custom validate function with class-validator
Reach for this when the project already uses class-validator for request DTOs and you want one validation vocabulary. The decorators are the same ones the ValidationPipe uses on incoming bodies; only the input differs — process.env here, the request payload there.
// src/config/env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, IsString, Max, Min, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Test = 'test',
Production = 'production',
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV!: Environment;
@IsNumber()
@Min(0)
@Max(65535)
PORT!: number;
@IsString()
DATABASE_URL!: string;
}
// Must be synchronous, and must RETURN the validated object.
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
// Turns "3000" into 3000 based on the declared property type.
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
// src/app.module.ts
// import { validate } from './config/env.validation.js';
// ConfigModule.forRoot({ isGlobal: true, validate })
Joi 18 and the libraryOptions change
This is the shape to use when upgrading an existing Nest 10 or 11 project. If you move a Joi schema across untouched, the symptom is that allowUnknown and abortEarly appear to be ignored, because they are now read from the wrong level of the options object.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// Joi needs version 18 or later: that is the version that
// implements Standard Schema, which is how @nestjs/config 12 talks
// to every validation library.
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'test', 'production')
.default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().required(),
}),
validationOptions: {
// In @nestjs/config 4 these sat directly on validationOptions.
// In 12 they move under libraryOptions. For Joi the package still
// defaults to abortEarly: false and allowUnknown: true, so you only
// pass this to opt out of those defaults.
libraryOptions: { allowUnknown: true, abortEarly: false },
},
}),
],
})
export class AppModule {}
Telling TypeScript the config was validated
Worth doing once the schema exists, because it removes both the explicit type arguments and the non-null assertions that otherwise spread through services. The second generic is a promise you make to the compiler, so only pass true when forRoot actually validates and supplies defaults for those keys.
// src/config/env.schema.ts
import { z } from 'zod';
export const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().min(1),
});
export type Env = z.infer<typeof envSchema>;
// src/health/health.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Env } from '../config/env.schema.js';
@Injectable()
export class HealthService {
// First generic: the shape of the configuration.
// Second generic true: it was validated, so get() does not return undefined.
constructor(private readonly config: ConfigService<Env, true>) {}
describe() {
// { infer: true } makes get() read the type from the generic above
// instead of needing an explicit type argument. port is number here.
const port = this.config.get('PORT', { infer: true });
return { port };
}
}
Discussion