NestJS in Production · Configuration, Testing, Data and Operations

Validated Configuration and Dynamic Modules

Load configuration once, validate it at startup, and provide it through a dynamic module with forRoot().

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A ConfigModule.forRoot() dynamic module that validates environment variables with Zod at startup and provides a typed AppConfig object, plus a MailModule.forRoot({ from }) that receives options from its importer. Invalid configuration stops the app with a readable message.

The idea

  • Read and validate configuration once at startup; inject a typed object instead of reading process.env everywhere.
  • A dynamic module is a static method (forRoot, register, forRootAsync) returning a module definition with providers built from options.
  • global: true makes a module's exports available everywhere (useful for configuration, use sparingly elsewhere).
  • Failing at startup is better than failing on the first request that needs a missing value.

The code

ConfigModule.forRoot() with Zod and a configurable MailModule
import "reflect-metadata";
import { DynamicModule, Inject, Injectable, Module } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { z } from "zod";

const Schema = z.object({
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.url(),
  MAIL_FROM: z.email(),
});
type AppConfig = z.infer<typeof Schema>;
const APP_CONFIG = Symbol("APP_CONFIG");

@Module({})
class ConfigModule {
  static forRoot(env: Record<string, string | undefined>): DynamicModule {
    const parsed = Schema.safeParse(env);
    if (!parsed.success) {
      throw new Error("invalid configuration: " + parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; "));
    }
    return { module: ConfigModule, global: true, providers: [{ provide: APP_CONFIG, useValue: parsed.data }], exports: [APP_CONFIG] };
  }
}

const MAIL_OPTIONS = Symbol("MAIL_OPTIONS");
@Injectable()
class MailService {
  constructor(@Inject(MAIL_OPTIONS) private opts: { from: string }) {}
  welcome(to: string) { return `From: ${this.opts.from} To: ${to} Subject: Welcome`; }
}
@Module({})
class MailModule {
  static forRootAsync(): DynamicModule {
    return { module: MailModule, providers: [MailService,
      { provide: MAIL_OPTIONS, inject: [APP_CONFIG], useFactory: (c: AppConfig) => ({ from: c.MAIL_FROM }) }], exports: [MailService] };
  }
}

async function boot(env: Record<string, string | undefined>) {
  @Module({ imports: [ConfigModule.forRoot(env), MailModule.forRootAsync()] })
  class AppModule {}
  const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
  console.log(app.get<AppConfig>(APP_CONFIG).PORT, "|", app.get(MailService).welcome("[email protected]"));
  await app.close();
}

async function main() {
  await boot({ DATABASE_URL: "postgres://db.internal/shop", MAIL_FROM: "[email protected]" });
  try {
    await boot({ PORT: "abc", MAIL_FROM: "nope" });
  } catch (e) {
    console.log((e as Error).message);
  }
}
main();

Output

3000 | From: [email protected] To: [email protected] Subject: Welcome
invalid configuration: PORT Invalid input: expected number, received NaN; DATABASE_URL Invalid input: expected string, received undefined; MAIL_FROM Invalid email address

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • process.env.X scattered through services, with no single place that lists required settings.
  • Defaults that hide missing production configuration (a default database URL pointing at localhost).
  • Making many modules global, which hides dependencies; reserve it for configuration and similar infrastructure.

Key points

  • Validate configuration at startup and inject a typed object.
  • Dynamic modules (forRoot, forRootAsync) build providers from options.
  • useFactory with inject derives options from other providers such as config.

Further reading: Dynamic modules · Configuration

Practice questions

What is a dynamic module in NestJS?

Answer

A module created by a static method (forRoot, register, forRootAsync) that returns its definition with providers configured from the caller's options.

Why validate configuration at startup rather than on use?

Answer

Missing or invalid settings fail immediately with a clear message during deployment, instead of causing errors on a rarely used code path in production.

How do you build a provider from another provider's value?

Answer

A factory provider: { provide: TOKEN, inject: [OTHER_TOKEN], useFactory: (other) => ... }.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?