Files
What you'll learn
- Register ConfigModule.forRoot() once, globally, and explain what it reads and in what order of precedence
- Shape raw environment variables into nested configuration with a factory passed to load, and read it with ConfigService.get('a.b') and a default
- Validate the environment before the application boots with a validate function built on class-validator and class-transformer
Configuration
The cats API has a capacity: three cats, then a 400. Until now that number lived in the service, next to the code that enforces it, and so did the shelter's name. Both are wrong there. The same program runs on your laptop, in a test and in production, and each of those wants a different capacity, a different database host, a different API key. Configuration is everything that changes between environments while the code stays the same, and the convention Nest follows is the one most servers follow: it comes from environment variables, and during development those variables come from a .env file that is never committed.
ConfigModule reads the environment
@nestjs/config wraps the dotenv library. Import its module once in the root module:
@Module({
imports: [ConfigModule.forRoot()],
})
export class AppModule {}
forRoot() reads .env from the project root, merges it into process.env, and provides a ConfigService any class can inject to read a value:
@Injectable()
export class MailerService {
constructor(private readonly config: ConfigService) {}
from(): string {
return this.config.get<string>('MAIL_FROM', 'noreply@example.com');
}
}
The second argument is the default, returned when the variable is missing. Precedence goes the other way from what you might expect: a variable already set in the real environment wins over the same name in .env, so a deployment can override the file without editing it.
Two options matter on day one. isGlobal: true makes ConfigService injectable in every module without each of them importing ConfigModule; without it, MailerModule above would need imports: [ConfigModule]. And envFilePath points at a different file or a list of them, ['.env.local', '.env'], the first match winning.
Custom configuration files
Raw environment variables are flat strings: DATABASE_PORT is "5432", not 5432, and nothing groups it with DATABASE_HOST. A configuration factory turns the flat environment into the shape the application wants to read:
// config/configuration.ts
export default () => ({
database: {
host: process.env.DATABASE_HOST ?? 'localhost',
port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
},
});
Pass it to load, and the nested keys become readable with a dotted path:
ConfigModule.forRoot({ load: [configuration] });
const port = this.config.get<number>('database.port');
load takes a list, so a large application splits its configuration by concern. The factory is plain code: it can parse, default, compute, and it runs once at boot.
Validating before the app boots
A misspelt variable is the worst kind of bug: the app starts, and fails an hour later when the value is first used. validate closes that gap. It receives the raw environment and either returns it or throws, and if it throws, NestFactory.create() fails with the message, before a single request is served.
The docs' recipe uses the two libraries the validation lesson introduced. A class describes what the environment must contain, plainToInstance turns the strings into that class with implicit conversion ("5432" becomes 5432), and validateSync checks the decorators:
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;
}
The returned instance is what ConfigService stores, which has a useful side effect: after validation, config.get('PORT') is the number the class declared, not the string the file held.
Configuration namespaces
registerAs('database', () => ({ ... })) gives a factory a name and a typed token, so a class can inject exactly its slice, @Inject(databaseConfig.KEY) private db: ConfigType<typeof databaseConfig>, instead of the whole service. It is the same mechanism as load, with a name attached; keep it in mind for the database lessons, where a module's options come from configuration.
Your task
The shelter's name and capacity move to .env, which already holds SHELTER_NAME and SHELTER_CAPACITY.
- In
AppModule, registerConfigModuleonce for the whole application: global, loadingconfig/configuration.ts, validating withconfig/env.validation.ts. configuration.tsreturns{ shelter: { name, capacity } }from the environment, with'Unnamed shelter'and10as defaults and the capacity as a number.env.validation.tsconverts, validates and returns the environment, throwing with the errors when a variable is wrong.CatsServicereadsshelter.nameandshelter.capacitythroughConfigService, reports the rawSHELTER_CAPACITYinshelter(), and givesmotto()a default for a variable that is not in the file.
Do step 4 before step 1 and run: the service asks for a ConfigService nobody provides, and Nest's message names it. Then, with everything in place, change SHELTER_CAPACITY in .env to abc and run again to see what validation buys you.
When it fails
Nest can't resolve dependencies of the CatsService (?), argumentConfigService:ConfigModuleis not registered, or it is registered withoutisGlobalandCatsModuledoes not import it.capacityis10although.envsays 3: the factory is not inload, or it does not readSHELTER_CAPACITY; the default took over.capacityFromEnvis"3", a string:validateis missing, or it returns the raw config instead of the converted instance.An instance of EnvironmentVariables has failed the validation: - property SHELTER_CAPACITY has failed the following constraints: max, min, isInt: validation works, and.envholds something that is not a number between 1 and 50. That failure at boot is the point.
Remember
ConfigModule.forRoot()reads.envintoprocess.env; a real environment variable wins over the file.isGlobal: trueprovidesConfigServiceeverywhere;get(key, default)reads a value,get('a.b')a nested one from a loaded factory.load: [factory]shapes flat strings into typed, nested configuration.validateruns before the app boots, and what it returns is whatConfigServiceholds.
Stuck? Show a hint
app.module.ts: ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate }). configuration.ts returns { shelter: { name, capacity } } from process.env with parseInt for the capacity. env.validation.ts: plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true }), then validateSync(instance, { skipMissingProperties: false }); throw new Error(errors.toString()) when the array is not empty. In CatsService, inject ConfigService and read 'shelter.name', 'shelter.capacity' and the raw 'SHELTER_CAPACITY'; motto() reads 'SHELTER_MOTTO' with a default.
Press Run tests to start the app. Its log appears here.Graded endpoints
configuration.ts shaped .env into shelter.name and shelter.capacity, read through ConfigService; capacityFromEnv is a number because validate() converted it before ConfigService stored it
SHELTER_MOTTO is not in .env, so ConfigService.get returns the default
The first of three cats the capacity allows
Second of three
Third of three
SHELTER_CAPACITY=3 in .env reached the service as shelter.capacity, and the message carries the shelter's name
Three cats