When I started with NestJS, I didn't understand modules, interceptors, guards, and other advanced techniques the framework offers. As a result, I built insecure applications with poor practices. Over time, I learned the hard way which patterns truly matter for production-grade applications.
After several years working with NestJS, I've collected capabilities I want to share. One is creating a custom config module where you can declare, load, and validate environment variables. After reading this post, you'll build more robust, production-ready applications.
Setting up the project
First, I'll install the Nest CLI globally to access the framework's capabilities. I like using the CLI to boost my productivity when working with Nest.
npm install -g @nestjs/cli
With the CLI installed, I can create a new Nest project using the command nest new. I prefer working with pnpm as my package manager, but you can choose whichever works best for you. I'll name my project config-module-example.
nest new config-module-example
Now I'll install the config service from Nest.
pnpm i @nestjs/config
With the project set up, it's time to start building. I'll begin by creating a new module.
Creating the Module
As a best practice, I create a common folder for configurations, database schemas, definitions, validations, and other non-business logic utilities. Let's start there.
Using the CLI, I'll run this command:
nest g mo common/config
This command creates a config module inside the common folder and a config.module.ts file where I'll define my custom configuration module—don't confuse it with the built-in one.
To make my module global, I'll add the @Global decorator so I can use it throughout the entire application. I also need to import the @nestjs/config package but rename it as NestConfigModule to avoid confusion with my own config module.
import { ConfigModule as NestConfigModule } from '@nestjs/config';
Now I'll configure how the module behaves. Since I'm working with .env files, I need to tell the Nest container where to find them and enable caching. I also need to export this module so it can be used as a provider.
With these configurations in place, my module looks like this:
@Global()
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
envFilePath: [
`.env${process.env.NODE_ENV ? `.${process.env.NODE_ENV}` : ''}`,
],
cache: true,
}),
],
exports: [NestConfigModule],
})
export class ConfigModule {}
Pay attention to the envFilePath property—this tells the module to use the environment-specific .env file if NODE_ENV is present, or the default one if not. With my config module defined, I'll now define the types for working.
Defining Configuration Types
For this example, I'll declare some basic environment variables. You can extend them as needed.
Under the common folder, I'll create a types folder with a file called app.types.ts. Here I'll define the AppConfig type:
export type AppConfig = {
port: number;
nodeEnv: 'development' | 'production' | 'test' | 'staging';
};
With the types defined, I can now set up the application's configuration.
Working with Configurations
With the types defined, I'll now create the configuration file. Inside the config folder, I'll create a configurations folder and add a file called app.config.ts. I need to import the registerAs function from @nestjs/config. This function registers a configuration namespace that I can reference later. Here's how it looks:
export default registerAs(
'app',
(): AppConfig => ({
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: (process.env.NODE_ENV || 'development') as
| 'development'
| 'production'
| 'test'
| 'staging',
}),
);
I'm using registerAs with two arguments: the namespace token 'app' and a factory function that returns the AppConfig object. This maps my environment variables to a type-safe configuration. But what happens if someone loads a corrupt .env file with invalid variables? To prevent this, the next step is to define and validate a schema.
Validating Schemas with Joi
With the configuration in place, I need to ensure the .env file doesn't have corrupt or invalid variables. To do this, I'll create a schema with validations using Joi.
First, I'll install Joi:
pnpm i joi
Next, I'll create a validations folder under my config module to hold my schemas. Using Joi, I'll define the validation schema:
export const appValidationSchema = Joi.object({
PORT: Joi.number().default(3000),
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
});
The schema validates that PORT is a number and NODE_ENV is one of the allowed environment values, with sensible defaults for both. With all the pieces ready, it's time to wire everything together and make this configuration work.
Wiring It All Together
Now I'll connect all the pieces. I'll return to the config.module.ts file and add the configuration loader and validation schema:
@Global()
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
load: [appConfig],
validationSchema: appValidationSchema,
envFilePath: [
`.env${process.env.NODE_ENV ? `.${process.env.NODE_ENV}` : ''}`,
],
cache: true,
}),
],
exports: [NestConfigModule],
})
export class ConfigModule {}
The load property tells the module which configuration files to load, and validationSchema ensures the environment variables are valid before the app starts. With the module complete, let's test that it works.
Testing the Configuration works
To test the configuration, I'll log the port and environment at startup. In my main.ts file, I'll inject the ConfigService and retrieve the configuration values:
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port', { infer: true }) as number;
const nodeEnv = configService.get<string>('app.nodeEnv', { infer: true }) as string;
if (nodeEnv === 'development') {
console.log(`Application is running in ${nodeEnv} mode`);
console.log(`Listening on port ${port}`);
}
Before running the application, I'll create a .env file with the environment variables:
PORT=3000
NODE_ENV=development
Now I'll run pnpm run start:dev. If everything is configured correctly, the application will start and display the development logs.
Final Thoughts
Creating a custom configuration module in NestJS is essential for building robust, maintainable applications. In this article, I've shown you how to structure environment variables, validate them with Joi schemas, and organize them in a way that scales with your project.
This setup prevents the configuration mistakes I made early in my NestJS journey. Type-safe configurations with validation catch errors before they reach production, making your applications more reliable and easier to maintain.
You can find the complete code in my Github repository: https://github.com/RubenOAlvarado/config-module-example
Happy coding—see you in the next one!
Top comments (0)