Skip to content
NestJS best practices 4 min read

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.example with the full list of keys and dummy values, but never the real .env. A leaked .env is 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: false reports 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,
  };
}
SourceBest forNotes
.env fileLocal developmentGit-ignored; mirror keys in .env.example
Injected env varsCI, containers, PaaSOverride file values; no secrets on disk
Secrets managerProduction credentialsRotatable, audited, fetched at startup
--env-file flagQuick 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 validationSchema with abortEarly: false so misconfiguration fails fast at startup with every error listed.
  • Group settings into registerAs namespaces and inject ConfigType<typeof ns> for full type safety instead of raw process.env.
  • Let real environment variables override .env files so platforms and CI can configure deploys without code changes.
  • Git-ignore every .env* file and commit a .env.example documenting 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.
Last updated June 14, 2026
Was this helpful?