Foundations Updated 2026-09 View as Markdown

Configuration and Environment

An application that refuses to start on a missing secret is far better than one that starts and fails on the first request that needs it.

shell
npm i @nestjs/config
src/app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,                      // available everywhere, no re-import
      envFilePath: ['.env.local', '.env'], // first match wins
      cache: true,
    }),
  ],
})
export class AppModule {}
typescript
@Injectable()
export class MailService {
  constructor(private readonly config: ConfigService) {}

  send() {
    const key = this.config.get<string>('MAIL_API_KEY');
  }
}

That works, and it has two problems worth fixing straight away: nothing checks the variable exists, and get<string>() is an assertion rather than a guarantee — the type parameter is you telling TypeScript what to believe.

Validate at boot#

The most valuable change you can make here.

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

export const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),

  DATABASE_URL: z.url(),
  DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),

  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('15m'),

  REDIS_URL: z.url().optional(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export type Env = z.infer<typeof EnvSchema>;
typescript
ConfigModule.forRoot({
  isGlobal: true,
  validate: (raw) => EnvSchema.parse(raw),   // throws at startup, not at 2am
})

Now a missing DATABASE_URL or a 12-character JWT_SECRET stops the application from starting, with a message naming the variable. Compare that with the alternative: the app boots, serves traffic, and fails on the first request that touches the database.

Note z.coerce.number() — every environment variable is a string, so numeric settings need coercion. A PORT of "3000" passed where a number is expected is a classic source of confusing failures.

Fail fast, loudly, at boot

This is the whole point. A configuration error should be impossible to deploy, not a runtime surprise. It also means your container orchestrator sees a failed start and stops the rollout, rather than routing traffic to a broken instance.

Typed access#

config.get<string>('X') is an unchecked assertion. Namespaced configuration gives you real types:

src/config/database.config.ts
export default registerAs('database', () => ({
  url: process.env.DATABASE_URL!,
  poolSize: Number(process.env.DATABASE_POOL_SIZE ?? 10),
}));

export type DatabaseConfig = ConfigType<typeof databaseConfig>;
typescript
ConfigModule.forRoot({
  isGlobal: true,
  load: [databaseConfig, authConfig],
  validate: (raw) => EnvSchema.parse(raw),
})
typescript
@Injectable()
export class Repo {
  constructor(
    @Inject(databaseConfig.KEY)
    private readonly config: DatabaseConfig,   // fully typed, no generics
  ) {}

  connect() {
    return createPool(this.config.url, { max: this.config.poolSize });
  }
}

The service now depends on a small typed object rather than on ConfigService and a set of string keys. That is easier to test — you pass a plain object — and a renamed variable becomes a compile error rather than an undefined.

Async module configuration#

Modules that need config at construction time use the forRootAsync convention:

typescript
JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    secret: config.getOrThrow<string>('JWT_SECRET'),
    signOptions: { expiresIn: config.getOrThrow<string>('JWT_EXPIRES_IN') },
  }),
}),

getOrThrow rather than get — it fails loudly instead of silently passing undefined as your signing secret, which would produce tokens anyone can forge.

Per-environment files#

.env                 committed — safe defaults only, no secrets
.env.local           gitignored — your machine
.env.test            committed — test values, no real credentials
.env.production      NEVER committed
.gitignore
.env.local
.env.*.local
.env.production

Commit a .env.example listing every variable with a placeholder. It is the only documentation of your configuration surface that stays current, because a missing entry breaks someone's setup immediately.

.env.example
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
JWT_SECRET=generate-with-openssl-rand-base64-32

In production, prefer real secret management#

.env files are a development convenience. In production, inject configuration through your platform — container environment variables, a secrets manager, or your orchestrator's secret objects. A .env file on a production host is a file that can be read, copied and backed up.

Whatever the source, the schema validation above still applies: the application checks what it received and refuses to start if it is wrong.

Exercise#

typescript
// 1. Write a schema requiring: NODE_ENV, PORT (number, default 3000),
//    DATABASE_URL (a URL), STRIPE_SECRET_KEY (starts with "sk_"),
//    and an optional SENTRY_DSN.
// 2. Wire it into ConfigModule so a bad value stops startup.
// 3. Create a namespaced `stripe` config and inject it, typed,
//    into a PaymentsService — with no ConfigService and no generics.

Common questions#

Should I validate configuration if I have a .env.example?#

Yes. The example file documents intent; validation enforces it. They fail differently — the example does not stop someone deploying with JWT_SECRET=changeme, and the schema does.

ConfigService.get or namespaced config?#

Namespaced, for anything beyond a couple of values. It gives you real types instead of an assertion, makes services testable with a plain object, and turns a renamed variable into a compile error rather than an undefined at runtime.

Is isGlobal: true a good idea?#

For configuration, yes — it is genuinely needed everywhere and re-importing ConfigModule in twenty modules is noise. That is the narrow case where @Global() is justified; resist it for domain modules, where the explicit import edge is the thing that keeps the dependency graph readable.

Get the NestJS agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.