Back to Blog
NestJSConfigModuleZodEnvironment VariablesModule Series

Why We Did Not Just Use @nestjs/config, and Built Our Own ConfigModule Instead

Based on real project experience, this article explains why we did not just use @nestjs/config and instead built our own ConfigModule to better handle multi-environment loading, Zod-based validation, config overrides, and more predictable deployments.

RaytonX
4 min read

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_NAME inside 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
  • values provides 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.