NestJS course · Module 1: NestJS Basics

Configuration Management - The Province Register

7 min read
In this lesson8

A database address written into the code works perfectly - on your laptop. On the staging server the database sits elsewhere, on production elsewhere again, and its password should never reach the repository at all. Three environments, one codebase, three different values - and none of them can be hard-coded.

Rome had a custom for this. Orders were written once, but a province's resources were recorded in the local register: how much grain, which road, who governs. The same legion marched into Gaul and into Egypt, reading the local register each time. In an application that register is the environment variables.

Four steps

Configuration enters a project in a fixed order - worth knowing as a whole, because skipping a step produces misleading errors:

  1. Create the .env file with keys and values.
  2. Register ConfigModule.forRoot() in the root module.
  3. Inject ConfigService through the constructor.
  4. Read the values with configService.get('KEY').

Step one: the .env file

Environment variables live in a .env file in the project directory:

1DB_HOST=localhost
2DB_PORT=5432
3DB_PASSWORD=tribute123
4JWT_SECRET=super-secret-key

The format is minimal: name, equals sign, value. No quotes, no spaces around the equals sign, no semicolons at the end.

The file's name is not a convention you choose - it is .env, not config.json, settings.yaml or environment.xml. You will meet those formats in other ecosystems, but not here.

The file itself never goes into the repository - you add it to .gitignore. Instead you commit a .env.example with the same keys and empty or sample values, so a colleague knows what to fill in.

Step two: registering the module

Configuration support comes from the @nestjs/config package. Mind the name - there are no @nestjs/env, @nestjs/settings or @nestjs/environment packages. You install it with:

1npm i @nestjs/config

We register it in the root module:

1@Module({
2  imports: [
3    ConfigModule.forRoot({
4      envFilePath: '.env',
5      isGlobal: true,
6    }),
7  ],
8})
9export class AppModule {}

The notation has a fixed frame: ConfigModule.forRoot({, then the options - here envFilePath: '.env', and isGlobal: true - and finally }). The order of the options inside the object does not matter; what matters is that a comma separates them.

envFilePath points at the file to load; with the default name it can be omitted.

isGlobal: true makes ConfigService available in every module with no additional imports. Without that option you would have to import ConfigModule in each module separately - and usually half the application uses it. Note what this option does not do: it encrypts nothing, does not limit visibility to the root module, and does not refresh configuration on restart.

Steps three and four: reading

You inject ConfigService like any other dependency:

1@Injectable()
2export class DatabaseService {
3  constructor(private configService: ConfigService) {}
4
5  getConnection() {
6    const host = this.configService.get('DB_HOST');
7    const port = this.configService.get<number>('DB_PORT', 5432);
8
9    return { host, port };
10  }
11}

A read is three parts in a fixed order: this.configService, .get, ('DB_HOST').

Two details are worth knowing. The <number> notation is a type parameter - it tells TypeScript what you expect, because values from a .env file always arrive as text. The second argument, 5432 here, is a default value used when the key is missing. That is convenient, but be careful: a default password or a default JWT secret is a ready-made hole - for such values it is better that the application fails to start than that it starts with anything at hand.

Checking at startup

Since a missing key yields undefined, a failure surfaces only at first use - sometimes hours later. Better to check the full set at once:

1ConfigModule.forRoot({
2  isGlobal: true,
3  validationSchema: Joi.object({
4    DB_HOST: Joi.string().required(),
5    DB_PORT: Joi.number().default(5432),
6    JWT_SECRET: Joi.string().required(),
7  }),
8});

validationSchema describes which keys you expect and of what type. At startup ConfigModule compares it with the contents of .env and aborts the launch if anything is missing - with a message naming the key outright.

That trades a three-in-the-morning outage for an error at deployment time. Joi is a separate schema description library, not part of NestJS: you install it with npm i joi (NestJS 12 requires Joi 18 or later) and import it with import * as Joi from 'joi';. @nestjs/config accepts such a schema in the validationSchema option.

Notice that each key has either .required() or .default(...). Combining both makes no sense: when a required key is missing, validation stops the startup before the default value could kick in.

Where a value comes from

The same key can live in several places at once. When it is both in the system environment variables (e.g. set on the server) and in the .env file, the system variable wins - the file will not override it. NestJS itself loads only .env; other files go in envFilePath, e.g. envFilePath: ['.env.production', '.env'], and then for a repeated key the first file on the list wins. The default value from configService.get('KEY', defaultValue) applies only when the key is nowhere at all.

Configuration groups: registerAs

As keys multiply, it is handy to group them by topic. The registerAs function from @nestjs/config creates a named group:

1import { registerAs } from '@nestjs/config';
2
3export const databaseConfig = registerAs('database', () => ({
4  host: process.env.DB_HOST,
5  port: parseInt(process.env.DB_PORT ?? '5432', 10),
6  name: process.env.DB_NAME,
7}));

The first argument is the group name, the second a factory that returns an object with the values. Note the parseInt: environment variables are text, so you convert numbers yourself. You load the group with the load option - ConfigModule.forRoot({ load: [databaseConfig] }) - and read it with a dot: this.configService.get('database.host').

Summary

The province's register is read on the spot, the code is one for all of them:

  • configuration lives outside the code, because the same application runs in several environments,
  • four steps in order: the .env file → ConfigModule.forRoot() in the root module → injecting ConfigService → configService.get('KEY'),
  • variables live in a .env file - not config.json, not settings.yaml, not environment.xml,
  • format: name, equals sign, value; the file stays out of the repository, you commit .env.example,
  • the package is @nestjs/config; @nestjs/env, @nestjs/settings and @nestjs/environment do not exist,
  • registration: ConfigModule.forRoot({, options separated by commas (e.g. envFilePath: '.env', and isGlobal: true), finally }),
  • isGlobal: true makes ConfigService available in all modules with no extra imports - it encrypts nothing and restricts nothing,
  • reading: this.configService + .get + ('DB_HOST'); values from .env always arrive as text,
  • get's second argument is a default value - do not give one to passwords or secrets,
  • validationSchema with Joi checks the full set of keys at startup and aborts the launch when one is missing; a key has .required() or .default(...), not both,
  • a system variable wins over the .env file, and with several files in envFilePath the first one wins,
  • registerAs('database', () => ({ ... })) groups keys; you load the group with load and read it with get('database.host').

This is the module's last lesson. You can now build a module, a controller and a service, expose a REST API, describe data with DTOs and move configuration out of the code - everything an application needs to go out into the world. For now remember: the code is one for every province; only the register they read on arrival differs.

Code for this lesson: src/config-management.ts
1// Configuration Management - Managing Imperium Resources
2import { Module, Injectable } from '@nestjs/common';
3
4console.log("Configuration Management - quaestor of the imperium!");
5
6// ===========================================
7// 1. ConfigModule - basic configuration
8// ===========================================
9
10// app.module.ts
11// import { ConfigModule } from '@nestjs/config';
12//
13// @Module({
14//   imports: [
15//     ConfigModule.forRoot({
16//       isGlobal: true,       // Available across the entire imperium
17//       envFilePath: '.env',  // Path to config file
18//     }),
19//   ],
20// })
21// export class AppModule {}
22
23// ===========================================
24// 2. Using ConfigService
25// ===========================================
26
27// import { ConfigService } from '@nestjs/config';
28
29@Injectable()
30export class LegionConfigService {
31  // In a real app: constructor(private configService: ConfigService)
32
33  getPort(): number {
34    // return this.configService.get<number>('PORT', 3000);
35    return 4000; // Default imperium port
36  }
37
38  getDatabaseUrl(): string {
39    // return this.configService.get<string>('DATABASE_URL');
40    return 'mongodb://localhost:27017/imperium';
41  }
42
43  getJwtSecret(): string {
44    // return this.configService.get<string>('JWT_SECRET');
45    return 'roma-aeterna-secret';
46  }
47
48  isProduction(): boolean {
49    // return this.configService.get('NODE_ENV') === 'production';
50    return false;
51  }
52}
53
54// ===========================================
55// 3. Custom configuration files
56// ===========================================
57
58// config/legion.config.ts
59export const legionConfig = () => ({
60  legion: {
61    maxSoldiers: 6000,
62    minExperience: 1,
63    ranks: ['Miles', 'Optio', 'Centurio', 'Tribunus', 'Legatus'],
64    defaultProvince: 'Roma',
65  },
66  database: {
67    host: process.env.DB_HOST || 'localhost',
68    port: parseInt(process.env.DB_PORT || '5432', 10),
69    name: process.env.DB_NAME || 'imperium_db',
70  },
71  security: {
72    jwtExpiration: '7d',
73    bcryptRounds: 12,
74    maxLoginAttempts: 5,
75  },
76});
77
78// ===========================================
79// 4. Configuration validation with Joi
80// ===========================================
81
82// import * as Joi from 'joi';
83//
84// ConfigModule.forRoot({
85//   validationSchema: Joi.object({
86//     NODE_ENV: Joi.string()
87//       .valid('development', 'production', 'test')
88//       .default('development'),
89//     PORT: Joi.number().default(4000),
90//     DATABASE_URL: Joi.string().required(),
91//     JWT_SECRET: Joi.string().required(),
92//   }),
93//   validationOptions: {
94//     abortEarly: true,
95//   },
96// })
97
98console.log("\n=== PODSUMOWANIE CONFIGURATION ===");
99console.log("ConfigModule.forRoot() - loading configuration");
100console.log("ConfigService.get() - retrieving values");
101console.log("load: [config] - custom configuration files");
102console.log("validationSchema - environment variable validation");
103

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which NestJS package is used for managing configuration and environment variables?

  2. 2. In which file do we store environment variables in a NestJS project?

  3. 3. What does setting isGlobal: true in ConfigModule.forRoot() mean?

  4. 4. Which package is most commonly used for validating environment variables in NestJS?

  5. 5. What is the main function of the main.ts file in NestJS?

  6. 6. What is the file naming convention in NestJS?

  7. 7. What is the correct order of HTTP request processing in NestJS?

Hands-on tasks in the game

  • Code editor

    Register ConfigModule.forRoot() in AppModule and use ConfigService.get('DB_HOST') in a service

  • Vertical ordering

    Arrange the steps for configuring environment variables from first to last:

  • Click in order

    Arrange the elements of ConfigModule registration in the correct order:

  • Horizontal ordering

    Arrange the elements for reading an environment variable via ConfigService:

  • Code editor

    Write a registerAs factory that groups DB_HOST, DB_PORT, and DB_NAME variables into a 'database' object

  • Vertical ordering

    Arrange NestJS configuration sources from highest to lowest priority:

  • Horizontal ordering

    Arrange the elements of the Joi schema validation for the PORT variable:

  • Click in order

    Arrange the elements of an environment variable definition in the .env file:

  • Vertical ordering

    Arrange the stages of starting a NestJS application from beginning to end:

  • Code editor

    Configure ConfigModule.forRoot() with validationSchema using Joi to validate PORT and DATABASE_URL

  • Horizontal ordering

    Arrange the elements of the ConfigModule import in the correct order:

  • Vertical ordering

    Arrange the files of a NestJS module from the most important (defining) to implementation:

  • Horizontal ordering

    Arrange the elements for creating a NestJS application instance:

  • Click in order

    Arrange the elements of the HTTP request lifecycle in NestJS in the correct order:

  • Code editor

    Create a bootstrap function that creates an app with NestFactory.create(), sets up a global ValidationPipe, and listens on port 3000

Useful articles