Configuration & Environment Variables
Manage settings across environments in NestJS with @nestjs/config: .env files, ConfigService, validation of required variables and namespaced configuration.
On this page 12 sections
Introduction
Your app behaves differently in different places: on your laptop it talks to a local database, in production to a managed one; locally the JWT secret can be anything, in production it must be strong and private. Configuration lets the same code run everywhere by reading these values from the environment instead of hard-coding them.
- Why settings belong in environment variables.
- How to load a .env file with ConfigModule.
- How to read values safely with ConfigService.
- How to fail fast when required variables are missing.
- How to configure other modules (like JWT) from ConfigService.
Why Environment Variables?
- Security — secrets like passwords and API keys never get committed to Git.
- Portability — the same build runs in development, testing and production.
- Flexibility — hosting platforms (Render, Railway, AWS, Docker) all inject settings as environment variables.
Installing @nestjs/config
npm install @nestjs/configCreate a .env file in the project root:
# .envPORT=3000DATABASE_HOST=localhostDATABASE_PORT=3306DATABASE_USER=rootDATABASE_PASSWORD=secretDATABASE_NAME=shopJWT_SECRET=change-me-to-a-long-random-stringThen load it once, globally, in AppModule:
// src/app.module.tsimport { Module } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // ...feature modules ],})export class AppModule {}Add .env to your .gitignore immediately. Commit a .env.example file instead, listing every variable name with placeholder values, so teammates know what to set.
Reading Values with ConfigService
import { Injectable } from '@nestjs/common';import { ConfigService } from '@nestjs/config';
@Injectable()export class MailService { constructor(private readonly config: ConfigService) {}
getSender() { // with a default value if the variable is not set return this.config.get<string>('MAIL_FROM', 'no-reply@example.com'); }
getPort() { // getOrThrow fails loudly if the variable is missing return this.config.getOrThrow<number>('PORT'); }}You can use ConfigService in main.ts too, by fetching it from the app instance:
const app = await NestFactory.create(AppModule);const config = app.get(ConfigService);await app.listen(config.get<number>('PORT', 3000));Environment variables are always strings. The generic in get<number>() only changes the TypeScript type — it does not convert "3306" into 3306. Convert explicitly, or use a validation schema (below) that coerces types.
Validating Configuration
If DATABASE_HOST is missing, it is far better for the app to refuse to start than to crash later on the first request. ConfigModule supports a Joi validation schema:
npm install joiimport Joi from 'joi';
ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), PORT: Joi.number().port().default(3000), DATABASE_HOST: Joi.string().required(), DATABASE_PORT: Joi.number().default(3306), JWT_SECRET: Joi.string().min(32).required(), }),});Click Run to see what this code prints.
Namespaced Configuration
As settings grow, group them into typed objects with registerAs():
// src/config/database.config.tsimport { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({ host: process.env.DATABASE_HOST, port: parseInt(process.env.DATABASE_PORT ?? '3306', 10), username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, name: process.env.DATABASE_NAME,}));
// src/app.module.tsConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] });
// anywhereconst dbHost = this.config.get<string>('database.host');Async Module Configuration
Modules like JwtModule and TypeOrmModule need configuration values at import time. Their registerAsync()/forRootAsync() variants let you build the options from ConfigService:
JwtModule.registerAsync({ global: true, inject: [ConfigService], useFactory: (config: ConfigService) => ({ secret: config.getOrThrow<string>('JWT_SECRET'), signOptions: { expiresIn: '1h' }, }),}),This replaces the hard-coded process.env.JWT_SECRET from lesson 9 and guarantees the .env file has been loaded before the value is read. You will use the same pattern for the database in the next lesson.
Common Mistakes
- Committing .env with real secrets to Git.
- Reading process.env directly inside module decorators before ConfigModule has loaded the file.
- Assuming get<number>() converts strings to numbers.
- Forgetting isGlobal: true and then getting "can't resolve ConfigService" errors in feature modules.
Best Practices
- Load ConfigModule once in AppModule with isGlobal: true.
- Validate every required variable at startup.
- Group related settings with registerAs() namespaces.
- Use forRootAsync()/registerAsync() with ConfigService for other modules.
- Keep a .env.example file up to date.
Frequently Asked Questions
Yes — pass envFilePath: [.env.${process.env.NODE_ENV}, '.env'] to forRoot(). The first file that defines a variable wins.
Yes. By default, variables already set in the process environment (for example by your hosting platform) take precedence over values in the .env file.
Key Takeaways
- @nestjs/config loads .env files and exposes values through ConfigService.
- ConfigModule.forRoot({ isGlobal: true }) makes ConfigService available everywhere.
- A Joi validationSchema makes the app fail fast on missing or invalid settings.
- registerAs() groups settings into namespaces like database.host.
- Async module variants (registerAsync/forRootAsync) build options from ConfigService.
Summary
Your app now reads its settings safely from the environment and refuses to start without the essentials. Next, you will replace the in-memory arrays used so far with a real database, using TypeORM.