Skip to main content

How to manage configuration in NestJS with configmodule?

ConfigModule loads .env files into NestJS's dependency injection container and exposes them as a singleton ConfigService you can inject anywhere in your app.

Theory

TL;DR

  • ConfigModule reads .env at app bootstrap, parses key=value pairs, stores them in ConfigService
  • isGlobal: true means one import in AppModule, access everywhere without re-importing
  • config.get<T>('KEY', defaultValue) returns typed values with an optional fallback
  • Add a Joi schema to validationSchema and the app refuses to start if required vars are missing
  • registerAs() groups related config into namespaced objects like database.host

Quick setup

bash
npm install @nestjs/config
typescript
// app.module.ts import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // inject ConfigService without re-importing ConfigModule envFilePath: '.env', cache: true, // O(1) reads after first parse }), ], }) export class AppModule {}

That is the full setup. Now ConfigService is available in any injectable class across the entire application.

Key difference from plain dotenv

Manual dotenv pollutes process.env globally and gives you raw strings with no types, no defaults, no validation. ConfigModule wraps dotenv under the hood and puts the result into NestJS's DI container as a singleton. You get typed reads, default values, Joi validation at startup, and caching. No scattered process.env calls across every service file.

When to use

  • DB credentials, API keys, port numbers: load once in AppModule, inject in services
  • Multi-environment setup: pass an array to envFilePath, NestJS picks the first file that exists
  • Apps following 12-factor principles: config lives outside the code, changes without a redeploy
  • Not for compile-time constants (use const for those) or standalone Node scripts outside NestJS (use dotenv directly there)

How it works internally

At app bootstrap, NestJS reads envFilePath (default .env), parses each KEY=value line using dotenv, and stores the result in ConfigService's internal Map. When you call config.get<T>('KEY'), it looks up that Map, coerces the string to type T, and applies your default if the key is missing. With cache: true, the first read stores the result and every subsequent call skips the Map lookup. If you pass a validationSchema, the schema runs before the app starts. Any invalid or missing required value throws immediately and kills the process with a readable message.

Typed, namespaced config with registerAs()

Flat keys like DB_HOST and DB_PORT work fine for small apps. For anything larger, registerAs() groups related vars into objects:

typescript
// config/database.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('database', () => ({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), name: process.env.DB_NAME || 'mydb', })); // app.module.ts ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }) // any.service.ts const host = this.configService.get<string>('database.host'); const port = this.configService.get<number>('database.port');

The dot notation in get() maps to nested objects created by registerAs(). Much cleaner than manually prefixing every flat key.

Multiple .env files

typescript
ConfigModule.forRoot({ envFilePath: [ `.env.${process.env.NODE_ENV}.local`, `.env.${process.env.NODE_ENV}`, '.env.local', '.env', ], })

NestJS processes the array left to right and stops once a key is found. A key in .env.development.local wins over the same key in .env. In production on Kubernetes, set ignoreEnvFile: true and let real environment variables from Secrets take over.

Common mistakes

1. Forgetting isGlobal: true

typescript
// Wrong: every module must import ConfigModule separately ConfigModule.forRoot() // not global // Fix: one call in AppModule, available everywhere ConfigModule.forRoot({ isGlobal: true })

Without isGlobal, injecting ConfigService in a module that does not import ConfigModule throws a provider-not-found error. Easy to miss in unit tests where you bootstrap modules manually.

2. No validation, app crashes later

typescript
// Wrong: returns NaN silently if DB_PORT='abc' in .env const port = this.configService.get<number>('DB_PORT'); // Fix: Joi schema, app refuses to start with a clear error message validationSchema: Joi.object({ DB_PORT: Joi.number().default(5432).required(), })

Skipping validation is the single fastest way to get a 3am incident from a missing JWT_SECRET in a new environment. Joi catches it at startup with a readable message instead of a cryptic DB connection error 10 minutes into a deploy.

3. config.get() in constructor without cache: true

typescript
// Wasteful in high-traffic apps without cache: true constructor(private config: ConfigService) { this.dbHost = config.get('DB_HOST'); // re-reads the Map on every instantiation } // Fix: set cache: true in forRoot() ConfigModule.forRoot({ isGlobal: true, cache: true })

In most apps this does not matter. In microservices with high request volume where services get recreated often, it adds measurable overhead.

4. Hardcoding .env path for Docker/Kubernetes

typescript
// Problem in production: K8s injects real env vars, no .env file exists envFilePath: '.env' // Fix: skip the file in production envFilePath: process.env.NODE_ENV !== 'production' ? '.env' : undefined // or simply: ignoreEnvFile: true when deploying to K8s

5. Missing abortEarly: false in Joi options

By default Joi stops at the first validation error. If your .env is missing five variables, you fix them one by one across five restarts. Set abortEarly: false to see the full list at once.

Real-world usage

  • TypeORM: TypeOrmModule.forRootAsync({ useFactory: (c: ConfigService) => ({ host: c.get('DB_HOST') }) })
  • Prisma: new PrismaClient({ datasources: { db: { url: config.get('DATABASE_URL') } } })
  • BullMQ: Redis URL from ConfigService in queue processor setup
  • Stripe / Twilio: API keys stored in .env, never hardcoded, read once in a constructor
  • Testing: use ConfigModule.forRoot({ cache: false }) and set process.env.KEY between tests

Follow-up questions

Q: What happens if a required variable is missing and there is no validation?
A: config.get('KEY') returns undefined. The app starts without errors. The crash surfaces later at the first DB query or API call, with a confusing message that points nowhere near the actual cause.

Q: How does the array in envFilePath work?
A: NestJS reads files left to right and stops once a key is found. Earlier entries in the array have higher priority. A key in .env.development.local overrides the same key in .env.

Q: Does config.get('DB.HOST') work with a flat .env file?
A: No. Dot notation maps to nested objects created by registerAs() or custom load[] factories. For a flat key you write config.get('DB_HOST'), not config.get('DB.HOST').

Q: How do you test code that depends on ConfigService?
A: Two options. Pass { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('test-value') } } directly in the testing module providers. Or use ConfigModule.forRoot({ ignoreEnvFile: true, cache: false }) and set process.env variables before each test case.

Q: In microservices with a shared config repo, how do you override config per service without a redeploy?
A: Set ignoreEnvFile: true and inject configuration via Kubernetes ConfigMaps as real environment variables. For service-specific overrides, pass a custom factory to load: [configurationFactory] that reads a YAML file or calls Vault/AWS SSM. Restart the pod and the new config is live.

Examples

Basic: Reading DB credentials

typescript
// .env // DB_HOST=localhost // DB_PORT=5432 // DB_NAME=myapp // database.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @Injectable() export class DatabaseService { constructor(private config: ConfigService) {} getConnectionString(): string { const host = this.config.get<string>('DB_HOST', 'localhost'); // fallback if missing const port = this.config.get<number>('DB_PORT', 5432); const name = this.config.get<string>('DB_NAME'); return `postgres://${host}:${port}/${name}`; // Output: "postgres://localhost:5432/myapp" } }

get<T>() accepts a type parameter and a default value. If DB_PORT is absent from .env, the method returns 5432 rather than undefined.

Intermediate: Joi validation + TypeORM integration

typescript
// app.module.ts import * as Joi from 'joi'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(5432), DB_USER: Joi.string().required(), DB_PASS: Joi.string().required(), DB_NAME: Joi.string().required(), JWT_SECRET: Joi.string().min(32).required(), }), validationOptions: { abortEarly: false, // report all errors, not just the first }, }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ type: 'postgres', host: config.get('DB_HOST'), port: config.get<number>('DB_PORT'), username: config.get('DB_USER'), password: config.get('DB_PASS'), database: config.get('DB_NAME'), autoLoadEntities: true, synchronize: config.get('NODE_ENV') !== 'production', }), inject: [ConfigService], }), ], }) export class AppModule {}

If JWT_SECRET is shorter than 32 characters or DB_HOST is missing, the app prints the full list of validation errors and exits before any request hits the server.

Advanced: Namespaced config with registerAs()

typescript
// config/app.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ port: parseInt(process.env.PORT || '3000', 10), env: process.env.NODE_ENV || 'development', jwtSecret: process.env.JWT_SECRET, stripeKey: process.env.STRIPE_SECRET_KEY, })); // config/database.config.ts export default registerAs('database', () => ({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), name: process.env.DB_NAME || 'mydb', })); // app.module.ts ConfigModule.forRoot({ isGlobal: true, load: [appConfig, databaseConfig], // merge both into ConfigService }) // payment.service.ts @Injectable() export class PaymentService { private stripeKey: string; constructor(private config: ConfigService) { // dot-notation access to the namespaced 'app' object this.stripeKey = this.config.get<string>('app.stripeKey')!; } charge(amount: number) { console.log(`Charging with key: ${this.stripeKey.slice(0, 4)}****`); // Output: "Charging with key: sk_l****" } }

registerAs() separates concerns cleanly. The auth service reads from app.*, the DB service reads from database.*. Neither touches raw process.env directly, and neither knows about the other's configuration.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?