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.

SyntaxConfigModule.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

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.