This chapter follows Exception filters in NestJS.
Reading process.env.DATABASE_URL wherever it is needed works until it does not: a typo returns undefined, a missing variable is only noticed when the first query fails, and tests have to mutate global state. @nestjs/config moves configuration behind a provider. Values are loaded and checked once at startup, and code receives them through dependency injection like any other dependency.
Loading configuration
pnpm add @nestjs/configConfigModule.forRoot() reads a .env file from the project root, merges it with the real environment, and registers ConfigService:
import { Module } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'], cache: true, }), ],})export class AppModule {}isGlobalmakesConfigServiceinjectable everywhere without importingConfigModulein each feature module. Configuration is one of the few cases where a global module is the right call.envFilePathlists files in order of priority. When a key appears in several files, the first file wins.cachekeeps values in memory instead of readingprocess.envon every lookup.
Variables already set in the environment take precedence over every .env file. That is what lets the same build run in a container where the platform injects the real secrets. Keep .env.local out of version control. ignoreEnvFile: true skips files entirely, for environments that set everything from outside.
Reading values
ConfigService is a provider like any other:
import { Injectable } from '@nestjs/common';import { ConfigService } from '@nestjs/config';
@Injectable()export class MailService { constructor(private readonly config: ConfigService) {}
private get from(): string { return this.config.get<string>('MAIL_FROM', 'noreply@example.com'); }
private get apiKey(): string { return this.config.getOrThrow<string>('MAIL_API_KEY'); }}get() returns undefined, or the default you pass, when a key is missing. getOrThrow() throws instead, which suits values the feature cannot work without. Every value read from the environment is a string. get<number>('PORT') only changes the TypeScript type, not the value, which is one more reason for the validation step below.
Namespaced configuration
For more than a handful of settings, group them with registerAs() and convert types in one place:
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({ uri: process.env.MONGODB_URI, poolSize: parseInt(process.env.MONGODB_POOL_SIZE ?? '10', 10),}));Load the factory with ConfigModule.forRoot({ load: [databaseConfig] }). Values are then available as config.get('database.uri'), or, with full typing, injected as the whole namespace:
import { Inject, Injectable } from '@nestjs/common';import type { ConfigType } from '@nestjs/config';import databaseConfig from '../config/database.config';
@Injectable()export class CatsService { constructor( @Inject(databaseConfig.KEY) private readonly db: ConfigType<typeof databaseConfig>, ) {}}ConfigType infers the object’s type from the factory, so this.db.poolSize is a number and a renamed key breaks the build instead of returning undefined.
A feature module can register its own namespace with ConfigModule.forFeature(databaseConfig), so the root module does not have to know about every feature’s settings.
Validating at startup
A missing or malformed variable should stop the application before it serves traffic, not surface as a failed request an hour later. forRoot() accepts a validate function that receives the raw values and returns the validated ones. Any schema library works. With zod:
import { z } from 'zod';
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().int().default(3000), MONGODB_URI: z.string().url(), JWT_SECRET: z.string().min(32),});
export function validateEnv(config: Record<string, unknown>) { return envSchema.parse(config);}ConfigModule.forRoot({ isGlobal: true, validate: validateEnv });The returned object replaces the raw values, so PORT is now a real number with a default. A validationSchema option for joi schemas does the same job for teams that already use joi.
Configuration outside the injector
main.ts runs before any provider is injected into it, but the application object can still hand out ConfigService:
const app = await NestFactory.create(AppModule);const config = app.get(ConfigService);await app.listen(config.get<number>('PORT', 3000));Configuration in other formats, such as a YAML file, is a factory too: read and parse the file in a function and pass it to load. The Nest CLI does not copy non-TypeScript files to dist, so such a file has to be listed under compilerOptions.assets in nest-cli.json.
Modules that need configuration to set themselves up, such as a database module that needs a URI, read it through forRootAsync(). That is the subject of the next chapter.
Next: Dynamic modules in NestJS.
Check your understanding
Question 1 of 5
0 of 5 checked