Configuration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, published 2026-09-30) with @nestjs/config 12.0.1, Node.js 22 and 24 LTS, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. The examples use the Nest 12 CLI project layout (ESM template, Vitest and supertest), and the Fastify-specific code targets the Fastify adapter. Applications run in several environments (a laptop, CI, staging, production) and each one needs different values: a database host, a log level, a feature flag, a credential. Configuration is the mechanism that keeps those values out of the source code. In the Bookshelf application that grows across this section, configuration decides which PostgreSQL instance the app talks to, whether the metrics module is loaded and which port it binds. Nest has no configuration system in @nestjs/core; the official answer is the @nestjs/config package, covered here option by option, together with the lookup rules, validation, and the habits (twelve-factor, no committed secrets) that make it safe.

The model: sources, process.env and ConfigService

@nestjs/config is a small layer over dotenv. At bootstrap it gathers values from four sources, validates the environment, and exposes everything through one injectable, ConfigService:

flowchart LR shell["Shell / container env"] --> merge["Merge (process env wins)"] files[".env files (envFilePath)"] --> merge merge --> validate["Validate (schema or validate())"] validate --> proc["process.env (strings)"] validate --> venv["Validated env (coerced)"] load["load factories / registerAs"] --> internal["Internal config (typed objects)"] internal --> svc["ConfigService.get()"] venv --> svc proc --> svc

The four sources are the real process environment, one or more .env files, custom configuration factories passed to load (or registered with registerAs()), and the values returned by validation. This is the central design decision: configuration is data read at startup and injected, not a global you reach into at any time. The package is a Nest library, not part of the Nest core, and is independent of the HTTP platform: the same code works on Express and Fastify, in a microservice and in a standalone application context.

Install it with:

npm i --save @nestjs/config

Where configuration sits in the application lifecycle

The position matters because it explains most of the surprises. ConfigModule.forRoot() is an ordinary static method call inside the @Module() decorator, so it runs when the file that declares the module is imported, not when NestFactory.create() runs. It returns a Promise<DynamicModule>; Nest awaits that promise while it scans the module graph. The sequence is:

  1. Node loads app.module.js. forRoot() starts: it reads the .env files, merges them with process.env, runs the validation, and writes the validated values back into process.env.

  2. forRoot() resolves the ConfigModule.envVariablesLoaded promise (see ConfigModule.envVariablesLoaded).

  3. NestFactory.create() awaits the dynamic module, instantiates providers, and ConfigService becomes injectable. load factories and registerAs() namespaces are merged into ConfigService when their providers are created.

  4. Modules registered with ConfigModule.forFeature() merge their namespace during module initialisation, in an order you must not rely on (see Partial registration with forFeature()).

  5. Lifecycle hooks (onModuleInit, …​) run; see Lifecycle Events & Shutdown.

Two practical consequences follow. Variables must exist before the module file is imported (a missing variable fails the validation when Nest loads the module, and Nest logs an ExceptionHandler error and aborts the start). And in tests, because the options are evaluated at import time, you set process.env first and import the module afterwards (the example tests of this page use a dynamic import() for exactly that reason).

Getting started with ConfigModule.forRoot()

Import ConfigModule once, in the root module, and call forRoot():

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot()],
})
export class AppModule {}

With no options, the module loads .env from the directory the process was started in (process.cwd()), merges the pairs with the variables already present in process.env, and registers ConfigService. A sample .env file for the Bookshelf database (the password is deliberately absent; it comes from the environment, see Twelve-factor configuration and secrets):

DATABASE_HOST=localhost
DATABASE_NAME=bookshelf
DATABASE_USER=bookshelf_app
PORT=3000

When a key exists both in the real environment and in the .env file, the real environment wins (this is dotenv behaviour, and the whole point of twelve-factor: the deployment overrides the file). Set override: true to let the file win instead. When several files are listed in envFilePath, the first file that defines a key wins.

If some variables must be available before the ConfigModule is even evaluated (for instance the transport options you pass to NestFactory.createMicroservice()), use the Nest CLI’s --env-file option, which hands the file to Node.js' --env-file flag:

nest start --env-file .env

The CLI option may be repeated to load several files. In production, where you run node dist/main directly, the equivalent is node --env-file=.env dist/main (a Node.js flag), though in containers you normally inject real environment variables and ship no file at all.

Every forRoot() option

ConfigModule.forRoot(options) takes a ConfigModuleOptions object. All of these come from the package typings (dist/interfaces/config-module-options.interface.d.ts):

Option Default Effect

envFilePath

.env in process.cwd()

A path or an array of paths. With an array, the first file defining a key wins. Missing files are skipped silently.

ignoreEnvFile

false

Skip .env files entirely and read only the real environment (typical in containers).

isGlobal

false

Register ConfigModule as a global module, so feature modules need not import it.

load

none

Array of configuration factories (functions or registerAs() results, possibly wrapped in a Promise) that return typed, nested objects.

cache

false

Cache values read from process.env inside ConfigService.get() (see cache, expandVariables).

expandVariables

false

Expand ${NAME} references inside .env files. Pass true or an options object for dotenv-expand.

override

false

Let values from the .env file replace variables already present in process.env.

validationSchema

none

A Standard Schema (Zod, Valibot, ArkType, Joi 18+) that validates the environment (see Validating the environment).

validationOptions

none

Options passed to the Standard Schema validation; library-specific settings go under libraryOptions.

validate

none

A synchronous function that receives the raw environment object and returns the validated one (see A custom validate() function).

validatePredefined

true

When false, variables that were already in process.env before the module loaded are not fed into validation.

skipProcessEnv

false

ConfigService.get() ignores process.env and serves only custom configuration and validated variables.

parser

dotenv parser

A function (buffer: Buffer) ⇒ Record<string, any> that turns the file contents into key/value pairs (for example, a JSON file parsed with JSON.parse).

ignoreEnvVars

false

Deprecated alias of validatePredefined: false; use validatePredefined.

The rest of this page goes through them in the order they matter. A realistic root module combines several:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import appConfig from './app.config.js';
import databaseConfig from './database.config.js';
import { envSchema } from './env.schema.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      expandVariables: true,
      envFilePath: ['.env.local', '.env'],
      load: [appConfig, databaseConfig],
      validationSchema: envSchema,
    }),
  ],
})
export class AppModule {}

In the ESM template (the default from nest new in Nest 12) relative imports carry the .js extension, as above; in a CommonJS project (the Jest template) they do not. Nothing else in @nestjs/config differs between the two.

Env file selection: envFilePath, ignoreEnvFile, override

// one file
ConfigModule.forRoot({ envFilePath: '.development.env' });

// several: the first one that defines a key wins
ConfigModule.forRoot({ envFilePath: ['.env.development.local', '.env.development'] });

// no file at all: use only the process environment
ConfigModule.forRoot({ ignoreEnvFile: true });

// let the file win over variables already exported in the shell
ConfigModule.forRoot({ override: true });

The precedence rules with two files and a shell variable: with envFilePath: [a, b] and A defined in both files, ConfigService.get('A') returns the value from a; with B also exported in the shell, get('B') returns the shell value; with override: true it returns the file value. With ignoreEnvFile: true a key that exists only in a file is undefined.

isGlobal

By default a feature module that injects ConfigService must import ConfigModule. With isGlobal: true in the root forRoot() call, the module is global (see Modules) and every module can inject ConfigService without importing anything.

ConfigModule.forRoot({ isGlobal: true });

Custom configuration files with load

Raw environment variables are flat strings. A configuration factory turns them into a typed, nested object, with defaults and casts in plain TypeScript:

// config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT ?? '', 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST,
    port: parseInt(process.env.DATABASE_PORT ?? '', 10) || 5432,
  },
});

Pass it through load (an array, so you can load several files) and read it with dot notation: configService.get<string>('database.host'). Inside the factory process.env already holds the fully resolved variables (files merged with the real environment, validation applied).

A factory may read any format. YAML is the documented example, using js-yaml:

// config/yaml.configuration.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { load } from 'js-yaml';

const YAML_CONFIG_FILENAME = 'config.yaml';

export default () => {
  const config = load(
    readFileSync(join(import.meta.dirname, YAML_CONFIG_FILENAME), 'utf8'),
  ) as Record<string, any>;

  if (config.http.port < 1024 || config.http.port > 49151) {
    throw new Error('HTTP port must be between 1024 and 49151');
  }
  return config;
};

Three details bite here. First, js-yaml 5 (the current major) has named exports only, so import { load } from 'js-yaml' is the form that works; the docs' import yaml from 'js-yaml' fails with Cannot read properties of undefined (reading 'load') on v5. Second, import.meta.dirname is ESM only; in a CommonJS project use __dirname. Third, the Nest CLI copies only TypeScript output to dist, so non-TS assets such as config.yaml must be listed under compilerOptions.assets in nest-cli.json (for example "assets": [{ "include": "../config/*.yaml", "outDir": "./dist/config" }]), otherwise the file is missing at runtime. Also remember that validationSchema validates only environment variables, not load factories: validate those yourself inside the factory, as the port-range check above does.

ConfigService: get, getOrThrow, typing and defaults

Inject ConfigService (exported by @nestjs/config) like any provider:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

interface DatabaseConfig {
  host: string;
  port: number;
}

@Injectable()
export class SettingsService {
  constructor(private readonly config: ConfigService) {}

  snapshot() {
    return {
      // a plain environment variable, with a type hint
      user: this.config.get<string>('DATABASE_USER'),
      // dot notation into a custom configuration object
      host: this.config.get<string>('database.host'),
      // a default used when the key does not exist
      region: this.config.get<string>('REGION', 'eu-west-1'),
      // a whole nested object
      db: this.config.get<DatabaseConfig>('database'),
    };
  }

  requireSomething(): string {
    // throws a TypeError when the resolved value is undefined; the return type excludes undefined
    return this.config.getOrThrow<string>('SOMETHING_REQUIRED');
  }
}

get(path) returns undefined for a missing key; get(path, default) returns the default. getOrThrow(path) accepts the same arguments but throws when the value is undefined. Exposed through a controller, the missing key produces:

curl -i http://localhost:3000/settings/required
{ "statusCode": 500, "message": "Internal server error" }

and the log shows TypeError: Configuration key "SOMETHING_REQUIRED" does not exist. Prefer getOrThrow() for anything the application cannot work without, but better still validate at startup (see Validating the environment) so the failure happens before the first request, not during it.

Typing the service

ConfigService has two optional generics. The first constrains and infers keys; the second removes undefined from the results when strictNullChecks is on:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

interface EnvironmentVariables {
  PORT: number;
  TIMEOUT: string;
}

@Injectable()
export class TypedSettings {
  constructor(private readonly config: ConfigService<EnvironmentVariables, true>) {}

  port(): number {
    // with `infer: true` the type comes from the interface: `number`, not `number | undefined`
    return this.config.get('PORT', { infer: true });
  }
}

With { infer: true } a key that is not in the interface is a compile-time error, and nested objects work with dot notation (ConfigService<{ database: { host: string } }> then get('database.host', { infer: true })). Note that the generic is a compile-time promise only: the EnvironmentVariables interface does not validate anything, which is the job of Validating the environment.

A custom getter service gives you a more natural call site and one place for string-to-boolean conversions:

@Injectable()
export class ApiConfigService {
  constructor(private readonly configService: ConfigService) {}

  get isAuthEnabled(): boolean {
    return this.configService.get('AUTH_ENABLED') === 'true';
  }
}

Lookup order and the v11 precedence change

When you call get() the value is resolved from these places, first hit wins (this is the implementation in @nestjs/config 12.0.1, and it has not changed since the Nest 11 line):

  1. the custom configuration built from load factories and registerAs() namespaces (the "internal config")

  2. the validated environment (the object returned by validationSchema or validate, so it holds coerced values)

  3. process.env (unless skipProcessEnv: true)

  4. the default value you passed

This order changed with Nest 11 (@nestjs/config 4.0). Version 3.x consulted the validated environment first, then process.env, and only then the internal config, so an environment variable could silently override a value produced by a load factory. Since v4 the factories win, which makes them the right place to put derived values. The get() source of 3.3.0 and 4.0.0 shows the change. The test that pins it down:

process.env.PORT = '1111';
ConfigModule.forRoot({
  ignoreEnvFile: true,
  load: [() => ({ PORT: 'from-load' })],
  validationSchema: z.object({ PORT: z.coerce.number() }),
});
// configService.get('PORT') === 'from-load'
// without the `load` entry:   configService.get('PORT') === 1111 (a number, coerced by the schema)

Do not confuse this with the file versus environment rule of Getting started with ConfigModule.forRoot(), which is decided earlier, while forRoot() merges .env files into process.env (real environment first, unless override: true).

skipProcessEnv and validatePredefined

skipProcessEnv: true makes get() ignore process.env, so only load factories and validated variables are visible through ConfigService. It is a guard against code that accidentally reads a variable you never validated. (process.env itself is untouched; only ConfigService stops reading from it.)

validatePredefined (default true) controls whether variables that were set before the module loaded, for example PORT=3000 node dist/main, are fed to validation. With validatePredefined: false they are skipped. For example, PORT=notanumber makes a PORT: z.coerce.number().default(3000) schema fail by default; with validatePredefined: false the schema ignores the shell value, ConfigService.get('PORT') returns the default 3000, and process.env.PORT still holds the unvalidated notanumber. Use it sparingly: it hides exactly the kind of bad deployment value that validation exists to catch.

ConfigModule.forRoot({ skipProcessEnv: true });
ConfigModule.forRoot({ validationSchema: envSchema, validatePredefined: false });

cache, expandVariables

Reading process.env is slower than reading an object. cache: true stores each value read from process.env the first time get() returns it, so later reads are cheap. The trade-off is that a later change to process.env is not seen: if FOO is one when first read and is then reassigned to two, get('FOO') still returns one.

expandVariables: true expands references inside .env files:

APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL}
ConfigModule.forRoot({ expandVariables: true });
// ConfigService.get('SUPPORT_EMAIL') === 'support@mywebsite.com'

Without the option the value stays the literal string support@${APP_URL}. Pass an object instead of true to forward options to dotenv-expand.

set() and changes$

The package typings also expose ConfigService.set(path, value) and a changes$ observable (not covered in the official chapter). set() writes into the in-memory configuration, emits a { path, oldValue, newValue } event on changes$, and, for a string path, also writes the value into process.env as a string. That last effect is global to the process, which is why runtime mutation of configuration is best avoided; treat configuration as immutable after startup.

configService.changes$.subscribe((e) => console.log(e)); // { path: 'RUNTIME_FLAG', newValue: 'on' }
configService.set('RUNTIME_FLAG', 'on');

Namespaces with registerAs()

For larger applications group related settings into namespaces. registerAs(token, factory) returns a configuration function with a KEY (the injection token) and an asProvider() helper:

// config/database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST ?? 'localhost',
  port: parseInt(process.env.DATABASE_PORT ?? '', 10) || 5432,
  name: process.env.DATABASE_NAME,
  user: process.env.DATABASE_USER,
  password: process.env.DATABASE_PASSWORD,
  // never true in production: synchronize can drop columns and data
  synchronize: process.env.DB_SYNCHRONIZE === 'true' && process.env.NODE_ENV !== 'production',
}));

Load it with load: [databaseConfig] and read it two ways: by string, get<string>('database.host'), or injected with strong typing through ConfigType:

import { Inject, Injectable } from '@nestjs/common';
import { type ConfigType } from '@nestjs/config';
import databaseConfig from './database.config.js';

@Injectable()
export class SettingsService {
  constructor(
    @Inject(databaseConfig.KEY) private readonly db: ConfigType<typeof databaseConfig>,
  ) {}

  port(): number {
    return this.db.port; // typed number, no string key to mistype
  }
}

A pitfall specific to the current template: isolatedModules plus emitDecoratorMetadata is on, so a type used in a decorated constructor signature must be imported as a type. A plain import { ConfigType } from '@nestjs/config' fails the build with TS1272: A type referenced in a decorated signature must be imported with 'import type'. Write import { type ConfigType } from '@nestjs/config' (an inline type modifier is enough).

Namespaces as module options: asProvider()

A namespaced configuration can configure another module’s forRootAsync() directly. asProvider() returns { imports: [ConfigModule.forFeature(config)], useFactory: (c) ⇒ c, inject: [config.KEY] }, so the shape of the namespace must match what the target module expects:

// config/typeorm.config.ts
import { registerAs } from '@nestjs/config';
import type { TypeOrmModuleOptions } from '@nestjs/typeorm';

export default registerAs(
  'typeorm',
  (): TypeOrmModuleOptions => ({
    type: 'better-sqlite3',
    database: process.env.DATABASE_FILE ?? ':memory:',
    autoLoadEntities: true,
    synchronize: process.env.NODE_ENV !== 'production' && process.env.DB_SYNCHRONIZE === 'true',
  }),
);

// app.module.ts
@Module({
  imports: [TypeOrmModule.forRootAsync(typeormConfig.asProvider())],
})
export class AppModule {}

(The example uses SQLite so it runs anywhere; swap type and the connection fields for PostgreSQL in the Bookshelf app, see Data Overview & TypeORM.) When you need to choose the options programmatically rather than pass a namespace through, use a forRootAsync() factory, covered in Configuring other modules with forRootAsync().

Partial registration with forFeature()

Instead of loading every configuration in the root module, a feature module can register only its own namespace with ConfigModule.forFeature():

import { Inject, Injectable, Module, OnModuleInit } from '@nestjs/common';
import { ConfigModule, ConfigService, type ConfigType } from '@nestjs/config';
import mailerConfig from './mailer.config.js';

@Injectable()
export class MailerService implements OnModuleInit {
  from?: string;

  constructor(
    @Inject(mailerConfig.KEY) private readonly cfg: ConfigType<typeof mailerConfig>,
    private readonly config: ConfigService,
  ) {}

  onModuleInit() {
    // values merged by forFeature() are safe to read here, not in the constructor
    this.from = this.config.get<string>('mailer.from');
  }
}

@Module({
  imports: [ConfigModule.forFeature(mailerConfig)],
  providers: [MailerService],
  exports: [MailerService],
})
export class MailerModule {}

forFeature() runs during module initialisation and the order in which modules initialise is indeterminate. Reading a forFeature() value through ConfigService in another provider’s constructor may therefore find nothing yet. Reading it in onModuleInit() is safe because that hook runs only after the modules a provider depends on are initialised. The @Inject(mailerConfig.KEY) path does not have this issue because Nest resolves the dependency before constructing the class.

Validating the environment

A service that starts with a missing or malformed setting and fails on the first request is the worst failure mode. @nestjs/config validates at startup, and a failure aborts the bootstrap. Two mechanisms exist: a Standard Schema passed to validationSchema, and a hand-written validate() function.

Standard Schema: Zod, Valibot, ArkType

Since @nestjs/config 12 (Nest 12.0.0), validationSchema accepts any schema implementing the Standard Schema specification, so you are no longer tied to Joi. Zod is the documented default:

// config/env.schema.ts
import { z } from 'zod';

export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().int().min(0).max(65535).default(3000),
  DATABASE_HOST: z.string().default('localhost'),
  DATABASE_PORT: z.coerce.number().int().default(5432),
  DATABASE_NAME: z.string().min(1),
  DATABASE_USER: z.string().min(1),
  DATABASE_PASSWORD: z.string().min(1),
  DB_SYNCHRONIZE: z.stringbool().default(false),
});

export type Env = z.output<typeof envSchema>;

Pass it as validationSchema: envSchema (shown in Every forRoot() option). How it behaves:

  • Variables with a default are optional; variables without one are required. Environment variables are always strings, so numbers and booleans need coercion (z.coerce.number(), z.stringbool()).

  • ConfigService serves the schema’s output, so get('PORT') returns the number 4000, while process.env.PORT holds the string '4000' (validated values are written back to process.env as strings).

  • Variables the schema does not declare (PATH, HOME, …​) do not fail validation, and remain visible through ConfigService.

  • All failing variables are reported at once, one per line, in the form PATH: message. With PORT=abc the log shows Config validation error: PORT: Invalid input: expected number, received NaN, and the application does not start. With Valibot, a v.object({ PORT: v.pipe(v.string(), v.transform(Number)) }) schema works the same way.

validationOptions forwards settings to the library; because the type is the Standard Schema one, library-specific keys go under libraryOptions:

ConfigModule.forRoot({
  validationSchema: envSchema,
  validationOptions: {
    libraryOptions: {
      // options specific to your validation library
    },
  },
});

Joi 18 and the libraryOptions migration

Joi still works, but only from version 18, the first Joi release that implements Standard Schema; older Joi versions do not implement the specification and cannot be used with validationSchema. Joi settings that used to sit directly under validationOptions (allowUnknown, abortEarly) now go under validationOptions.libraryOptions. For Joi schemas the package keeps its historical defaults, allowUnknown: true and abortEarly: false, and merges what you pass on top:

import Joi from 'joi';

ConfigModule.forRoot({
  validationSchema: Joi.object({
    PORT: Joi.number().default(3000),
    DATABASE_NAME: Joi.string().required(),
  }),
  validationOptions: {
    libraryOptions: { allowUnknown: false, abortEarly: true },
  },
});

With Joi 18.2.9, a missing required key produces Config validation error: MUST: "MUST" is required, and with allowUnknown: false every unrelated variable in the process (even your shell’s) is rejected as "…​" is not allowed, which is why the default of true exists. nest upgrade moves existing Joi options for you. For new projects the docs recommend a Standard Schema library such as Zod.

A custom validate() function

When you want full control, pass a synchronous validate function that receives the raw environment and returns the validated object; throwing aborts the bootstrap. The documented version uses class-transformer and class-validator:

// config/env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, Max, Min, validateSync } from 'class-validator';

enum Environment {
  Development = 'development',
  Production = 'production',
  Test = 'test',
}

class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment;

  @IsNumber()
  @Min(0)
  @Max(65535)
  PORT: number;
}

export function validate(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated, { skipMissingProperties: false });
  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validated;
}

Register it with ConfigModule.forRoot({ validate }). With NODE_ENV=staging the application did not start and the error named NODE_ENV. The function is synchronous, so it cannot await a secrets manager: do that in a load factory or a forRootAsync() factory instead. validate and validationSchema are alternatives; if both are set, validate is used.

ConditionalModule: load a module only when configuration says so

ConditionalModule.registerWhen(module, condition, options?) imports a module only if a condition on the environment is true. The condition is either the name of a variable (loaded unless it equals false, case-insensitive; also loaded when the variable is not set) or a predicate receiving process.env:

import { Controller, Get, Module } from '@nestjs/common';
import { ConditionalModule, ConfigModule } from '@nestjs/config';

@Controller('metrics')
class MetricsController {
  @Get()
  metrics() {
    return { enabled: true };
  }
}

@Module({ controllers: [MetricsController] })
export class MetricsModule {}

@Module({
  imports: [
    ConfigModule.forRoot({ ignoreEnvFile: true }),
    // loaded unless ENABLE_METRICS is "false" (any case); also loaded when it is unset
    ConditionalModule.registerWhen(MetricsModule, 'ENABLE_METRICS'),
    // custom predicate and custom timeout
    ConditionalModule.registerWhen(
      MetricsModule,
      (env: NodeJS.ProcessEnv) => !!env['FOO'] && !!env['BAR'],
      { timeout: 10_000, debug: false },
    ),
  ],
})
export class AppModule {}

With ENABLE_METRICS=FALSE, curl http://localhost:3000/metrics returns 404; with the variable unset or true it returns {"enabled":true}. The options argument takes timeout (milliseconds, default 5000) and debug (default true, which logs a debug line when a module is skipped). The module waits for ConfigModule.envVariablesLoaded, so ConfigModule must also be loaded in the app; if the promise does not resolve within the timeout, the app fails to start. Because the registration is evaluated when the file is imported, the variable must be set at that moment (a change after import has no effect, a common reason for a test of this feature to fail).

ConfigModule.envVariablesLoaded

ConfigModule.envVariablesLoaded is a static promise that resolves once forRoot() has read the .env files and applied them to process.env. Use it when the shape of a module depends on a variable that may come from a file:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({})
export class S3StorageModule {}
@Module({})
export class DefaultStorageModule {}

export async function getStorageModule() {
  await ConfigModule.envVariablesLoaded;
  return process.env.STORAGE === 'S3' ? S3StorageModule : DefaultStorageModule;
}

// in a module: imports: [ConfigModule.forRoot(), getStorageModule()]

A promise is accepted in imports (Nest awaits it), so getStorageModule() can be listed directly. If ignoreEnvFile is true, the promise resolves immediately after forRoot() runs. ConditionalModule is built on this hook.

Using configuration in main.ts

main.ts runs outside the dependency injection container, but you can still read configuration: the application object is itself a resolver, so app.get(ConfigService) returns the service. This is where the HTTP platform shows up, because the port, CORS origin and (on Fastify) bind address are all deployment settings:

import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
  const config = app.get(ConfigService);
  const port = config.getOrThrow<number>('PORT');
  const origin = config.get<string>('CORS_ORIGIN');
  if (origin) {
    app.enableCors({ origin });
  }
  // Fastify binds to 127.0.0.1 by default; a container needs 0.0.0.0
  await app.listen(port, '0.0.0.0');
}
await bootstrap();

Run on Fastify with PORT=0, the server reported {"address":"0.0.0.0","family":"IPv4"}. The differences between platforms are confined to this file: Express (NestExpressApplication, the default) listens on all interfaces when you call app.listen(port), while Fastify listens on 127.0.0.1 unless you pass a host, which makes a Fastify container unreachable from outside until you set it (see Deployment). Reading the port from ConfigService rather than process.env means it passes through validation and coercion. The top-level await in the last line is valid in the ESM template; in CommonJS call bootstrap() without it.

Values needed before the module system exists (the options for NestFactory.createMicroservice(), for example) cannot come from ConfigService; use nest start --env-file or node --env-file for those, as described in Getting started with ConfigModule.forRoot(). See also Platform Agnosticism & Standalone Applications for reading config in a standalone context.

Configuring other modules with forRootAsync()

Most Nest modules (TypeOrmModule, JwtModule, CacheModule, and the dynamic modules you write, see Dynamic Modules) expose forRootAsync(), which accepts inject and a useFactory. Inject ConfigService and build the options from validated configuration:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'better-sqlite3',
        database: config.get<string>('DATABASE_FILE', ':memory:'),
        autoLoadEntities: true,
        // synchronize only outside production, and only when explicitly enabled
        synchronize:
          config.get('NODE_ENV') !== 'production' && config.get('DB_SYNCHRONIZE') === 'true',
      }),
    }),
  ],
})
export class AppModule {}

Why not just pass process.env.X to a synchronous forRoot()? Because the options of forRoot() are evaluated when the file is imported, which may be before the environment is complete; the factory of forRootAsync() runs during provider instantiation, after ConfigModule has loaded and validated everything, and can be async (for example to fetch a secret from a vault). The registered ConfigModule must be global or imported by the module that hosts forRootAsync().

Twelve-factor configuration and secrets

The twelve-factor app rule is: store configuration in the environment, strictly separated from code. What changes between deploys (credentials, hosts, flags) must not be in the repository, and must not be baked into the image. @nestjs/config follows the rule with its precedence (the real environment overrides files) and its ignoreEnvFile switch. In practice:

  • Never hard-code credentials, not in source, not in .env files that are committed, not in Docker images. A literal password string in the options of TypeOrmModule.forRoot() is the most common version of this mistake, and it appears in tutorial material. The book (Packt, 2025) shows that style of inline credential for the database; where you meet it, replace it with a registerAs() namespace or a forRootAsync() factory that reads from ConfigService, and rotate any credential that was ever committed.

  • Commit a .env.example with placeholder keys and no values for secrets, and list .env* (except the example) in .gitignore. Provide the real values as environment variables from your platform (see Kubernetes Secrets); require them in the schema, with no default, so a deployment without them fails at boot. Generate secrets with openssl rand -hex 32.

  • Never synchronize: true in production. TypeORM’s synchronize rewrites the schema to match entities on every start and can drop columns and data. It is a convenience for local development only (another pattern from the book that is wrong for production); drive it from configuration as above, forced to false when NODE_ENV is production, and use migrations instead (see Data Overview & TypeORM).

  • Do not leak configuration. Never return a whole configuration object from an endpoint or log it; pick the fields you expose. The /settings controller of the example returns { host, port } and deliberately omits the password, and the test asserts that the secret does not appear in the response.

  • Fail fast. Declare every required variable in the schema so a typo or an unset secret stops the process at startup with a message listing all the problems, rather than later in a request.

A complete startup against the example module:

DATABASE_NAME=bookshelf DATABASE_USER=bookshelf_app \
  DATABASE_PASSWORD="$(cat /run/secrets/db_password)" \
  node --env-file=.env dist/main
curl http://localhost:3000/settings
{
  "user": "bookshelf_app",
  "host": "localhost",
  "region": "eu-west-1",
  "db": { "host": "localhost", "port": 5432 },
  "port": 5432,
  "environment": "development"
}

The secret is read from a file mounted by the orchestrator, never written to disk by the application. For the broader picture see Security Best Practices, and for how tests override configuration see Testing.

Pitfalls

  • forRoot() runs at import time. Variables set later (including in a test’s beforeEach) are not seen by an already evaluated module. Set the environment first, import afterwards.

  • Everything arrives as a string. process.env.PORT is '3000', and 'false' is truthy. Coerce in the schema or in a factory.

  • get() is untyped by default. get<number>('PORT') is a hint, not a conversion; use a schema, registerAs() with casts, or the typed generics.

  • cache: true freezes process.env reads. Fine for configuration, wrong for values you expect to change at runtime.

  • forFeature() values in constructors. Read them in onModuleInit(), or inject the namespace by KEY.

  • validationSchema does not validate load factories and does not cover YAML files; validate those in the factory.

  • Importing ConfigType without type breaks the build under isolatedModules plus emitDecoratorMetadata.

  • Joi older than 18 is not a Standard Schema and fails at startup; upgrade it.

Where the book is outdated

The book (see the section bibliography) builds configuration around ConfigModule.forRoot() and a Joi validationSchema, which was the only validation path in @nestjs/config 3 and 4. That is now only one option: validation is Standard-Schema-based, Joi must be 18 or later with settings under libraryOptions, and Zod is the documented default. The get() lookup order also changed in the Nest 11 line (see Lookup order and the v11 precedence change), so material that says process.env always wins over a load factory describes @nestjs/config 3.x. The book’s inline database credentials and synchronize: true examples are errors you should not copy (see Twelve-factor configuration and secrets).

What changed recently

  • Nest 12.0.0 (2026-08-27): @nestjs/config moved from Joi-specific validation to Standard Schema. validationSchema accepts Zod, Valibot, ArkType and Joi 18+; library settings sit under validationOptions.libraryOptions; Joi keeps allowUnknown: true and abortEarly: false as defaults. nest upgrade rewrites these options for you.

  • Nest 11 (@nestjs/config 4.0): ConfigService.get() now reads load factories first, then validated variables, then process.env; skipProcessEnv and validatePredefined are part of the current option set (the older ignoreEnvVars is deprecated).

  • Nest CLI --env-file: nest start --env-file .env loads variables before the application starts, for settings needed ahead of the module system.

  • The package depends on dotenv 18 and dotenv-expand 13 as of @nestjs/config 12.0.1.