Configuration & Secrets Practices
Configuration is the seam between your code and the environment it runs in. The 12-factor approach treats config as data that lives in the environment — never hardcoded, never committed — and NestJS supports this directly through @nestjs/config. Done well, configuration is typed, validated at startup, namespaced by concern, and free of secrets in source control, so a misconfigured deploy fails loudly on boot instead of silently at 3 a.m.
Load environment-specific files
Register ConfigModule once, globally, so every provider can inject configuration without re-importing. Point it at a per-environment .env file and let real OS environment variables take precedence over file values — that ordering is what lets your container platform or CI override anything at deploy time.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
ignoreEnvVars: false,
envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
}),
],
})
export class AppModule {}
Install the package and keep your env files out of git from day one:
npm install @nestjs/config
echo ".env*" >> .gitignore
Commit a
.env.examplewith the full list of keys and dummy values, but never the real.env. A leaked.envis the single most common way production credentials end up on GitHub.
Namespace configuration with typed factories
Flat process.env access scatters magic strings everywhere and gives you no types. Instead, group related settings into namespaced factories with registerAs. Each namespace becomes a strongly typed object you inject by token, so database.port is a number, not the string | undefined that process.env always returns.
// src/config/database.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
name: process.env.DB_NAME ?? 'app',
}));
export type DatabaseConfig = ReturnType<typeof databaseConfig>;
const databaseConfig = registerAs('database', () => ({}) as any);
Load the namespace and inject it with full type safety:
// src/app.module.ts (excerpt)
import databaseConfig from './config/database.config';
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] });
// src/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { Inject } from '@nestjs/common';
import databaseConfig from '../config/database.config';
@Injectable()
export class DatabaseService {
constructor(
@Inject(databaseConfig.KEY)
private readonly db: ConfigType<typeof databaseConfig>,
) {}
connectionString(): string {
return `postgres://${this.db.user}@${this.db.host}:${this.db.port}/${this.db.name}`;
}
}
Validate configuration at startup
Invalid config should crash the process before it accepts a single request. Supply a Joi schema (or any validate function) so missing or malformed variables are caught during bootstrap with a clear message, not as a runtime NaN deep inside a query.
npm install joi
// src/app.module.ts (excerpt)
import * as Joi from 'joi';
ConfigModule.forRoot({
isGlobal: true,
validationOptions: { abortEarly: false },
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().port().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().port().default(5432),
DB_PASSWORD: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
}),
});
When a required secret is missing, the app refuses to start:
Output:
Error: Config validation error: "DB_PASSWORD" is required. "JWT_SECRET" length must be at least 32 characters long
at ConfigModule.forRoot
abortEarly: falsereports every problem at once. Without it, you fix one missing variable, restart, and discover the next — slow and frustrating during a deploy.
Keep secrets out of source control
Files are fine for local development, but production secrets belong in a managed store — AWS Secrets Manager, GCP Secret Manager, Vault, or your platform’s injected environment variables. Use a custom async loader so secrets are fetched at boot and exposed through the same ConfigService API, keeping call sites identical across environments.
// src/config/secrets.loader.ts
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
export async function loadSecrets() {
if (process.env.NODE_ENV !== 'production') return {};
const client = new SecretsManager({});
const res = await client.getSecretValue({ SecretId: 'app/prod' });
const parsed = JSON.parse(res.SecretString ?? '{}');
return {
DB_PASSWORD: parsed.dbPassword,
JWT_SECRET: parsed.jwtSecret,
};
}
| Source | Best for | Notes |
|---|---|---|
.env file | Local development | Git-ignored; mirror keys in .env.example |
| Injected env vars | CI, containers, PaaS | Override file values; no secrets on disk |
| Secrets manager | Production credentials | Rotatable, audited, fetched at startup |
--env-file flag | Quick scripts (Node 20+) | No dependency, but no validation |
Access config the typed way
Prefer injecting namespaced ConfigType objects as shown above. When you do reach for ConfigService, use the generic form and infer: true so you get precise types and an explicit error on missing required keys.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
const port = config.get<number>('PORT', { infer: true })!;
await app.listen(port);
}
bootstrap();
Best Practices
- Register
ConfigModule.forRoot({ isGlobal: true, cache: true })once so config is available everywhere without re-importing. - Always attach a
validationSchemawithabortEarly: falseso misconfiguration fails fast at startup with every error listed. - Group settings into
registerAsnamespaces and injectConfigType<typeof ns>for full type safety instead of rawprocess.env. - Let real environment variables override
.envfiles so platforms and CI can configure deploys without code changes. - Git-ignore every
.env*file and commit a.env.exampledocumenting required keys with placeholder values. - Fetch production secrets from a managed store via an async loader; never bake credentials into images or repos.
- Coerce and default values inside config factories so the rest of the app receives clean, fully typed objects.