Configuration with ConfigModule
Hard-coding ports, database URLs, and API keys into your source is a recipe for leaked secrets and brittle deployments. NestJS solves this with the official @nestjs/config package, a thin, type-aware wrapper around dotenv that loads .env files at startup and exposes every value through an injectable ConfigService. Register it once in the root module and any provider in the application can read configuration safely, with full TypeScript support. This page walks through installing the package, registering ConfigModule.forRoot, and reading values with ConfigService.get.
Installing the package
@nestjs/config ships separately from the core framework. Install it from npm; it already bundles dotenv as a dependency, so there is nothing else to add.
npm install @nestjs/config
Create a .env file in your project root. The Nest CLI does not generate one for you, and you should keep it out of version control.
# .env
PORT=4000
DATABASE_URL=postgres://localhost:5432/app
JWT_SECRET=super-secret-dev-key
FEATURE_FLAGS=beta,reporting
Add
.envto.gitignoreand commit a.env.examplewith placeholder values instead. Never check real secrets into source control.
Registering ConfigModule
Configuration belongs in the root AppModule. Call ConfigModule.forRoot() in the imports array. By default it reads a .env file from the project root and merges it into process.env.
The single most important option is isGlobal: true. Without it, you would have to import ConfigModule into every feature module that needs configuration. Marking it global registers ConfigService once and makes it injectable everywhere.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: ['.env.local', '.env'],
}),
],
})
export class AppModule {}
The most commonly used forRoot options are summarized below.
| Option | Type | Effect |
|---|---|---|
isGlobal | boolean | Registers ConfigService globally so feature modules need not re-import the module |
envFilePath | string | string[] | One or more .env files; earlier entries win when keys overlap |
cache | boolean | Caches process.env lookups in memory for faster reads |
ignoreEnvFile | boolean | Skips file loading and relies only on the real environment (useful in production/containers) |
expandVariables | boolean | Enables ${VAR} interpolation between variables inside .env |
When envFilePath lists multiple files, the first file that defines a key wins. Putting .env.local ahead of .env lets each developer override shared defaults without touching the committed file.
Reading values with ConfigService
Inject ConfigService like any other provider and call get. Pass a type parameter so the returned value is correctly typed — values from process.env are always strings, so get<number>('PORT') documents the intended shape but does not coerce. Use the second argument to supply a default when a key is absent.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000);
await app.listen(port);
console.log(`Listening on http://localhost:${port}`);
}
bootstrap();
Output:
Listening on http://localhost:4000
Inside a provider, inject ConfigService through the constructor. The getOrThrow variant is invaluable for required secrets: it throws immediately at resolution time if the key is missing, turning a silent undefined into a loud startup failure.
// src/auth/token.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class TokenService {
private readonly secret: string;
private readonly features: string[];
constructor(private readonly config: ConfigService) {
// Throws if JWT_SECRET is not defined — fail fast.
this.secret = this.config.getOrThrow<string>('JWT_SECRET');
// Parse a comma-separated list; default to an empty array.
this.features = this.config.get<string>('FEATURE_FLAGS', '').split(',').filter(Boolean);
}
isEnabled(flag: string): boolean {
return this.features.includes(flag);
}
}
get vs getOrThrow
| Method | Missing key behavior | Use when |
|---|---|---|
get<T>(key) | Returns undefined | The value is optional |
get<T>(key, default) | Returns the default | A sensible fallback exists |
getOrThrow<T>(key) | Throws at call time | The value is mandatory (secrets, connection strings) |
Configuration precedence
@nestjs/config does not blindly overwrite the runtime environment. Variables that already exist in process.env — for example those injected by a container orchestrator or CI system — take precedence over the same key in a .env file. This is exactly what you want in production: your .env file supplies local defaults while real deployments override them through genuine environment variables. Set ignoreEnvFile: true in production to lean entirely on the platform.
Best Practices
- Always register
ConfigModule.forRoot({ isGlobal: true })once inAppModulesoConfigServiceis injectable everywhere without repeated imports. - Keep real secrets out of git: gitignore
.env, commit a.env.example, and inject production values as real environment variables. - Use
getOrThrowfor mandatory keys so a missing secret fails loudly at startup rather than surfacing as a cryptic runtime bug. - Pass type parameters to
getand remember values are strings — coerce or parse numbers, booleans, and lists explicitly. - Enable
cache: trueto avoid repeatedprocess.envlookups in hot paths. - Layer
.envfiles viaenvFilePath(e.g..env.localbefore.env) so developers can override shared defaults without editing committed files.