open atlas
↑ Back to track
NestJS, zero to senior NEST · 03 · 01

The config module: typed, validated env

ConfigModule.forRoot wraps dotenv, validates env at boot with a schema (fail fast), and exposes a typed ConfigService. Use registerAs for namespaced config and inject it with ConfigType — never scatter process.env.

NEST Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

The service has been green in CI and staging for a week. You promote to prod, and 200ms after boot the first checkout request explodes: getaddrinfo ENOTFOUND undefined. The cause is one line in a payments client — new Stripe(process.env.STRIPE_SECRET_KEY) — and in prod that variable was never set, so it was undefined the whole time. Nothing crashed at boot because nothing checked. The process happily accepted traffic with a hollow config and only blew up when a real customer hit the one code path that used the missing value. The fix is not “remember to set the var” — it is to make the process refuse to start when its config is wrong.

ConfigModule wraps dotenv and centralises reads

@nestjs/config is a thin, opinionated layer over dotenv. You register it once with ConfigModule.forRoot(); it reads .env (and .env.${NODE_ENV} if you point it there), merges in process.env, optionally validates the result, and exposes everything through an injectable ConfigService. Marking it isGlobal: true means every feature module can inject ConfigService without importing ConfigModule again, and cache: true memoises lookups so config.get() doesn’t re-read process.env on every call.

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

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,                              // inject ConfigService anywhere, no re-import
      cache: true,                                 // memoise process.env reads
      envFilePath: `.env.${process.env.NODE_ENV}`, // per-environment file
      expandVariables: true,                       // ${HOST}:${PORT} interpolation in .env
      ignoreEnvFile: process.env.NODE_ENV === 'production', // prod: real env only, no file
    }),
  ],
})
export class AppModule {}

You then read values with a typed getter — config.get<string>('DATABASE_URL') — instead of reaching for process.env directly. The win is not syntax; it’s that ConfigService is a real provider. It’s injectable, so it participates in DI; it’s mockable, so tests can pass a fake config instead of mutating global process.env; and because reads are centralised, validation happens in exactly one place.

In production set ignoreEnvFile: true. A committed .env should never be the source of truth in prod — values come from the real environment (the orchestrator, the secrets manager, the platform), and reading a stale file is how staging credentials leak into a production deploy.

The senior point: validate at boot, fail fast

Ask yourself: when should a misconfigured deploy fail — at boot where an engineer can see it in the deploy log, or three hours later when a customer hits the broken code path? The answer shapes everything that follows.

The defining feature of ConfigModule is not loading — it’s validation. Pass a validationSchema (Joi) and Nest validates the merged environment once, at module init. If a required variable is missing or malformed, the process throws during bootstrap with a precise message and never starts. That converts a lurking, request-time undefined into a loud, immediate, deploy-time failure.

import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
    PORT: Joi.number().port().default(3000),
    DATABASE_URL: Joi.string().uri().required(),   // missing -> boot crashes
    STRIPE_SECRET_KEY: Joi.string().required(),    // the bug from the hook, caught at startup
  }),
  validationOptions: { abortEarly: false },        // report ALL bad vars, not just the first
});

Prefer Zod or a custom validate function if you’d rather not add Joi — Nest accepts any validate: (config) => validatedConfig that throws on failure. It runs at the same point in the lifecycle and gives the same fail-fast guarantee:

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),                  // throws at boot if absent / malformed
});

ConfigModule.forRoot({
  isGlobal: true,
  validate: (raw) => envSchema.parse(raw),         // ZodError -> process refuses to start
});

Either way the contract is the same: a malformed DATABASE_URL crashes startup with a clear message instead of silently being undefined until the first query in prod.

Namespaced config with registerAs and typed injection

A flat bag of config.get('DATABASE_HOST') strings doesn’t scale — the keys are stringly-typed and the grouping is implicit. registerAs('database', factory) defines a namespace: a named config object built from process.env, registered via forFeature or the load array, and — crucially — injectable as a strongly-typed unit.

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

export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST ?? 'localhost',
  port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
  url: process.env.DATABASE_URL,
}));
// any provider
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './database.config';

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

  connect() {
    // dbConfig.port is typed `number`, dbConfig.host is `string` — no casts, autocomplete works
    return `${this.dbConfig.host}:${this.dbConfig.port}`;
  }
}

ConfigType&lt;typeof databaseConfig> derives the static type straight from the factory’s return value, and databaseConfig.KEY is the DI token for that namespace. The result is config that is typed (the compiler knows port is a number), validated (the namespace is built from an env that passed the schema), and testable (inject a stub for databaseConfig.KEY in a unit test). Contrast that with process.env.DATABASE_PORT scattered across the codebase: untyped (always string | undefined), unvalidated, untestable, and invisible to DI.

process.env vs ConfigService vs a hand-rolled singleton

Why not just read process.env, or build one config.ts object yourself? The table makes the gap concrete:

PropertyRaw process.envHand-rolled singletonConfigService
TypesAlways string | undefinedOnly if you write themTyped via ConfigType / get<T>
ValidationNone — fails at first useDIY, easy to forgetSchema at boot — fail fast
DI / injectableNo — global accessNo — imported globalYes — a real provider
Mockable in testsMutate global env (leaky)Hard — module-level stateYes — provide a fake

The hand-rolled singleton gets you types and one read site, but it’s module-level state you can’t inject or cleanly mock, and the validation is whatever discipline you remember to write. ConfigService gives you all three — types, boot-time validation, and DI — for free.

Why this works

Why does validation belong at boot and not at first read? A request-time check only fires on the code path that touches the variable — the one route that calls Stripe, the one job that opens the DB. Until that path runs in prod, a missing var is invisible, so the failure surfaces hours after deploy, on a customer, far from the deploy that caused it. Boot-time validation collapses that distance: the process either starts with a complete, well-formed config or it doesn’t start at all, which is exactly the signal a deploy pipeline can act on (the new pods never become healthy, the rollout halts).

Pick the best fit

You're standing up config for a new Nest service. DATABASE_URL and STRIPE_SECRET_KEY are required, and you want type-safety plus a guarantee that a misconfigured deploy never reaches customers. What do you reach for?

Quiz

A required DATABASE_URL is missing in production. What happens with a validationSchema configured versus without one?

Quiz

You want a typed `database` config object (host: string, port: number) injectable into providers. Which approach delivers types plus DI?

Recall before you leave
  1. 01
    What does ConfigModule.forRoot actually do, and what do isGlobal, cache, and ignoreEnvFile each buy you?
  2. 02
    Explain 'validate at boot, fail fast' and how you wire it up with both Joi and a custom validate function.
  3. 03
    Why is registerAs + ConfigType better than scattering process.env, and how do you inject a namespace?
Recap

The config module is how a Nest service stops treating environment variables as ambient global strings and starts treating them as a typed, validated, injectable contract. ConfigModule.forRoot is a thin layer over dotenv: it loads .env (and .env.${NODE_ENV}), merges process.env, optionally validates, and exposes an injectable ConfigService you read with config.get<T>(); isGlobal lets any module inject it, cache memoises reads, and ignoreEnvFile: true in production keeps the source of truth in the real environment rather than a committed file. The senior point is fail-fast validation: a validationSchema (Joi) or a custom validate function (Zod) runs once at boot, so a missing or malformed DATABASE_URL crashes startup with a clear message — the new deploy never goes healthy — instead of lurking as undefined until the first request touches it in prod. For structure, registerAs(‘database’, factory) defines a namespace injected via @Inject(databaseConfig.KEY) typed as ConfigType<typeof databaseConfig>, giving config that is typed, validated, and mockable — everything that scattered, stringly-typed, unvalidated, un-injectable process.env reads are not. Choose ConfigService with boot-time validation over raw process.env or a hand-rolled singleton. Now when you see a service whose constructor reaches for process.env directly, you know exactly what to reach for instead — and why the deploy that drops that replacement will be safer than the one it replaces.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.