Configuration and Environment
An application that refuses to start on a missing secret is far better than one that starts and fails on the first request that needs it.
npm i @nestjs/config@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // available everywhere, no re-import
envFilePath: ['.env.local', '.env'], // first match wins
cache: true,
}),
],
})
export class AppModule {}@Injectable()
export class MailService {
constructor(private readonly config: ConfigService) {}
send() {
const key = this.config.get<string>('MAIL_API_KEY');
}
}That works, and it has two problems worth fixing straight away: nothing checks the variable exists, and get<string>() is an assertion rather than a guarantee — the type parameter is you telling TypeScript what to believe.
Validate at boot#
The most valuable change you can make here.
import { z } from 'zod';
export const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.url(),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().default('15m'),
REDIS_URL: z.url().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof EnvSchema>;ConfigModule.forRoot({
isGlobal: true,
validate: (raw) => EnvSchema.parse(raw), // throws at startup, not at 2am
})Now a missing DATABASE_URL or a 12-character JWT_SECRET stops the application from starting, with a message naming the variable. Compare that with the alternative: the app boots, serves traffic, and fails on the first request that touches the database.
Note z.coerce.number() — every environment variable is a string, so numeric settings need coercion. A PORT of "3000" passed where a number is expected is a classic source of confusing failures.
Fail fast, loudly, at boot
This is the whole point. A configuration error should be impossible to deploy, not a runtime surprise. It also means your container orchestrator sees a failed start and stops the rollout, rather than routing traffic to a broken instance.
Typed access#
config.get<string>('X') is an unchecked assertion. Namespaced configuration gives you real types:
export default registerAs('database', () => ({
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DATABASE_POOL_SIZE ?? 10),
}));
export type DatabaseConfig = ConfigType<typeof databaseConfig>;ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, authConfig],
validate: (raw) => EnvSchema.parse(raw),
})@Injectable()
export class Repo {
constructor(
@Inject(databaseConfig.KEY)
private readonly config: DatabaseConfig, // fully typed, no generics
) {}
connect() {
return createPool(this.config.url, { max: this.config.poolSize });
}
}The service now depends on a small typed object rather than on ConfigService and a set of string keys. That is easier to test — you pass a plain object — and a renamed variable becomes a compile error rather than an undefined.
Async module configuration#
Modules that need config at construction time use the forRootAsync convention:
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: config.getOrThrow<string>('JWT_EXPIRES_IN') },
}),
}),getOrThrow rather than get — it fails loudly instead of silently passing undefined as your signing secret, which would produce tokens anyone can forge.
Per-environment files#
.env committed — safe defaults only, no secrets
.env.local gitignored — your machine
.env.test committed — test values, no real credentials
.env.production NEVER committed.env.local
.env.*.local
.env.productionCommit a .env.example listing every variable with a placeholder. It is the only documentation of your configuration surface that stays current, because a missing entry breaks someone's setup immediately.
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
JWT_SECRET=generate-with-openssl-rand-base64-32In production, prefer real secret management#
.env files are a development convenience. In production, inject configuration through your platform — container environment variables, a secrets manager, or your orchestrator's secret objects. A .env file on a production host is a file that can be read, copied and backed up.
Whatever the source, the schema validation above still applies: the application checks what it received and refuses to start if it is wrong.
Exercise#
// 1. Write a schema requiring: NODE_ENV, PORT (number, default 3000),
// DATABASE_URL (a URL), STRIPE_SECRET_KEY (starts with "sk_"),
// and an optional SENTRY_DSN.
// 2. Wire it into ConfigModule so a bad value stops startup.
// 3. Create a namespaced `stripe` config and inject it, typed,
// into a PaymentsService — with no ConfigService and no generics.Common questions#
Should I validate configuration if I have a .env.example?#
Yes. The example file documents intent; validation enforces it. They fail differently — the example does not stop someone deploying with JWT_SECRET=changeme, and the schema does.
ConfigService.get or namespaced config?#
Namespaced, for anything beyond a couple of values. It gives you real types instead of an assertion, makes services testable with a plain object, and turns a renamed variable into a compile error rather than an undefined at runtime.
Is isGlobal: true a good idea?#
For configuration, yes — it is genuinely needed everywhere and re-importing ConfigModule in twenty modules is noise. That is the narrow case where @Global() is justified; resist it for domain modules, where the explicit import edge is the thing that keeps the dependency graph readable.
Get the NestJS agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.