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, anddb(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
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 {}
This already gives us:
Global config (no need to import it everywhere)
Environment-specific
.envloadingCaching 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';
};
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
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'),
});
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 {}
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)