Why We Did Not Just Use @nestjs/config β And How We Built Our Own ConfigModule
Across multiple NestJS projects, we kept finding ourselves rewriting the same configuration bootstrap logic: loading multiple env files, switching behavior by environment, validating with Zod, and handling override priority.
@nestjs/config solves a large part of the problem, but there were still a few pieces we had to rebuild every time. After repeating the same setup often enough, we decided to turn those conventions into @raytonx/config.
This article is not about saying the official package is insufficient. It is about what we ran into in real projects, the design decisions we made, and what the final result looks like.
Problem 1: Every project repeated the same env file loading order
@nestjs/config supports envFilePath, but it does not provide a built-in convention for automatic environment-based loading. In project after project, we ended up writing something like:
envFilePath: [
'.env',
'.env.local',
`.env.${process.env.NODE_ENV}`,
`.env.${process.env.NODE_ENV}.local`,
]
That exact block showed up in almost every codebase we maintained. In @raytonx/config, we turned that convention into a single keyword:
ConfigModule.forRoot({
envFilePath: 'auto',
})
"auto" loads files in the following order, with later values overriding earlier ones. process.env always has the highest priority:
.env
.env.local
.env.${NODE_ENV}
.env.${NODE_ENV}.local
process.env <- highest priority
This convention is inspired by tools like Vite and Create React App, so it feels immediately familiar to developers coming from modern frontend workflows.
Problem 2: Configuration was not strongly typed, and getOrThrow still returned string
The official ConfigService can support typing, but it depends on manually passing generics, and the final values are often still just strings. For example, PORT still needs to be parsed manually.
Our approach was to integrate Zod directly into module initialization, so validation and type coercion happen in one place:
import { z } from 'zod'
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
})
type AppConfig = z.infer<typeof configSchema>
ConfigModule.forRoot<AppConfig>({
isGlobal: true,
envFilePath: 'auto',
schema: configSchema,
})
After that, values returned through ConfigService<AppConfig> are already typed according to the schema. PORT is a number, not a string:
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService<AppConfig>) {}
get port(): number {
return this.config.getOrThrow('PORT') // number, not string
}
}
If validation fails, the application throws a ConfigValidationError during startup, including the failing field path and the relevant Zod error details, instead of letting the issue surface later at runtime.
That is the core design goal here:
expose configuration errors at startup, not after deployment
For teams shipping multiple backend services, this also reduces deployment mistakes and makes environment behavior more predictable.
Problem 3: Tests and special environments needed explicit overrides
Sometimes we need to override specific values during module initialization, for example:
- forcing a database connection in integration tests
- overriding
APP_NAMEinside a sub-application in a monorepo
@nestjs/config does not directly cover that use case. We introduced a values field for it:
ConfigModule.forRoot({
envFilePath: 'auto',
values: {
APP_NAME: 'api',
},
})
The priority rules are explicit and easy to reason about:
env files < values < process.env
values has higher priority than env files, but lower priority than process.env. That means system-level environment variables in CI/CD can always override code-level defaults, while code-level overrides still take precedence over local env files.
That matches how most teams intuitively expect configuration priority to work.
Full example
.env.development:
NODE_ENV=development
PORT=3000
DATABASE_URL=https://db.example.com
Variable expansion is also supported by default:
APP_HOST=localhost
APP_PORT=3000
APP_URL=http://${APP_HOST}:${APP_PORT}
src/app.module.ts:
import { Module } from '@nestjs/common'
import { ConfigModule } from '@raytonx/config'
import { z } from 'zod'
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
})
export type AppConfig = z.infer<typeof configSchema>
@Module({
imports: [
ConfigModule.forRoot<AppConfig>({
isGlobal: true,
envFilePath: 'auto',
schema: configSchema,
}),
],
})
export class AppModule {}
src/app.service.ts:
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@raytonx/config'
import type { AppConfig } from './app.module'
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService<AppConfig>) {}
get port(): number {
return this.config.getOrThrow('PORT')
}
}
Summary
On top of @nestjs/config, @raytonx/config adds three practical improvements:
envFilePath: "auto"turns multi-environment loading into a built-in convention- Zod-based schemas move validation and type coercion to startup time
valuesprovides a clear override mechanism for tests and multi-app setups
Install it with:
pnpm add @raytonx/config
The package and documentation are available on npm. If your team keeps rewriting configuration bootstrap logic across NestJS projects, this approach may be useful either directly or as a reference for building your own internal module.