DEV Community

Cover image for Developing a Tailored Config Module for NestJS Applications
Ruben Alvarado
Ruben Alvarado

Posted on

Developing a Tailored Config Module for NestJS Applications

Have you ever struggled with more environment variables than actual features? DATABASE_URL, SUPABASE_URL, JWT_SECRET, a couple of flags for local vs production, and maybe some “temporary” variables you promise you’ll clean up later.

If you read process.env directly everywhere, the codebase becomes fragile fast:

  • One typo silently breaks your connection.

  • One missing variable makes the app crash at runtime.

  • You end up debugging configuration instead of shipping.

What if there's a better, cleaner, and more professional way to handle this mess? In this post, we'll build a small, type-safe, validated Config Module that will serve as the foundation for the rest of this series.

Why a custom Config Module?

Nest already provides @nestjs/config, and it’s great. The issue is that most tutorials stop at “install it and call it a day”.

For a production-grade API, we want a little more:

  • One place to load and validate environment variables

  • Clear namespaces like app, and db (for now)

  • Type inference so configuration access is safe and discoverable

  • Fail fast validations (before the app starts)

This is especially important in our Personal Finance API because our next steps depend on stable configuration:

  • Drizzle needs a valid Postgres connection string

  • Local and production environments must behave predictably

Creating the module

We’ll create a module that is global and acts as the single source of truth for configuration.

Generate a config module (choose your own path):

nest g mo config
Enter fullscreen mode Exit fullscreen mode

Now set up the module using Nest’s ConfigModule, but keep it wrapped behind your own module.

Here’s the baseline:

// config/config.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';

@Global()
@Module({
  imports: [
    NestConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'],
    }),
  ],
  exports: [NestConfigModule],
})
export class ConfigModule {}
Enter fullscreen mode Exit fullscreen mode

This already gives us:

  • Global config (no need to import it everywhere)

  • Environment-specific .env loading

  • Caching for performance

Defining configuration namespaces (app, db)

Instead of scattering variable names throughout the codebase, we’ll create configuration “namespaces”. This keeps things organized and makes future posts easier to follow.

Example: app config. You can do the same for db.

// config/configurations/app.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs(
  'app',
  (): AppConfig => ({
    port: parseInt(process.env.PORT || '3000', 10),
    nodeEnv: (process.env.NODE_ENV || 'development') as AppConfig['nodeEnv'],
  }),
);

// config/types/app-config.types.ts
export type AppConfig = {
  port: number;
  nodeEnv: 'development' | 'production' | 'test' | 'staging';
};
Enter fullscreen mode Exit fullscreen mode

For this series, the important part is that by the time we connect Drizzle, we can read something like:

  • db.connectionString

Validating environment variables with Joi

Type safety is nice, but validation is what prevents bad configuration from reaching production. We’ll use Joi to validate env variables before the app boots.

Install Joi:

pnpm i joi
Enter fullscreen mode Exit fullscreen mode

Create a validation schema:

// config/validations/env.validation.ts
import * as Joi from 'joi';

export const envValidationSchema = Joi.object({
  PORT: Joi.number().default(3000),
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test', 'staging')
    .default('development'),
});
Enter fullscreen mode Exit fullscreen mode

Now wire everything together:

// config/config.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';

import appConfig from './configurations/app.config';
import { envValidationSchema } from './validations/env.validation';

@Global()
@Module({
  imports: [
    NestConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'],
      load: [appConfig],
      validationSchema: envValidationSchema,
    }),
  ],
  exports: [NestConfigModule],
})
export class ConfigModule {}
Enter fullscreen mode Exit fullscreen mode

Testing it quickly

Create a .env file with the variables you defined and boot the app. If any variable is missing or invalid, Nest will fail fast and tell you exactly what's wrong. That's the whole point.

If you see Nest application successfully started in the console, you nailed it.

Wrapping Up

At this point, you’ve built a configuration layer that is:

  • Centralized (one module)

  • Type-safe (structured config objects)

  • Validated (Joi schema)

  • Ready for the next steps (Drizzle + Supabase integration)

In the next post, we'll use this module to initialize Drizzle with the Supabase Postgres connection string and start defining our schema. But to be ready, we'll need the database config—so that's your homework. See you in the next one!

💡 Next post: Connecting Supabase Postgres to NestJS using Drizzle and our Config Module.


🔗 Code: https://github.com/RubenOAlvarado/finance-api/tree/v0.2.0

Top comments (0)