Skip to content

Configuration & environnement

TL;DR@nestjs/config charge .env, process.env, et expose un ConfigService typé. Le vrai senior move : valider au boot (Joi/Zod), namespacer par feature, interdire process.env partout sauf un fichier, et fail fast si une variable manque. Une app qui démarre avec un secret manquant et plante 30s plus tard est une app qui réveille l'astreinte.

🧠 Mental model — ASCII diagram + analogy

            .env.local  .env.test  Vault / SOPS / SSM
                 \         |              /
                  \        |             /
                   ▼       ▼            ▼
                  ┌────────────────────────┐
                  │   ConfigModule.forRoot │
                  │   load + validate      │
                  └──────────┬─────────────┘

              ┌──────────────┼────────────────┐
              ▼              ▼                ▼
       database.config   auth.config    feature-flags.config
              │              │                │
              ▼              ▼                ▼
        TypeOrmModule    AuthModule     FeatureFlagsModule

Analogie : ConfigModule est le comptoir d'enregistrement de l'aéroport. Personne ne monte dans l'avion sans avoir validé sa carte. Toute lecture de process.env ailleurs = quelqu'un qui saute la queue → catastrophe à 30 000 pieds.

Concept clé : séparer le chargement (où) de l'usage (comment). Le code métier dépend d'un type AppConfig, pas de process.env.DATABASE_URL.

Ordre de résolution (le détail qui fait perdre 2h)

@nestjs/config fusionne plusieurs sources avec une précédence stricte — connais-la par cœur, c'est la source n°1 des « ça marche en local, pas en prod » :

process.env (déjà présent au lancement)   ← gagne TOUJOURS
   ▲  surchargé par rien

.env (chargé par dotenv, NE surcharge PAS une var déjà dans process.env)

Conséquence non-intuitive : envFilePath: ['.env.production', '.env'] n'écrase jamais une variable déjà exportée par Kubernetes / le shell / le Dockerfile ENV. Donc en prod (où tout passe par l'env du conteneur), tes fichiers .env sont quasi ignorés — c'est voulu (12-factor), mais ça surprend. Pour tester un override local : unset la var shell d'abord.

Deuxième subtilité, validate vs load : Nest exécute validate(processEnv) sur la map brute process.env (strings), avant les factories load. Le retour de validate remplace la source que ConfigService expose pour les clés plates. Les namespaces de registerAs sont calculés après et lisent encore process.env directement — d'où l'importance de coercer/valider dans le schéma, pas dans les factories. Un registerAs('db', () => ({ ssl: process.env.SSL })) te rend la string "false" (truthy), pas un booléen.

Piège de coercition fantôme : validate retourne parsed.data avec PORT: 3000 (number), mais registerAs('http', () => process.env.PORT) relit process.env.PORT = "3000" (string). Les deux coexistent dans le même ConfigService. Règle staff : soit tout passe par le schéma validé (clés plates), soit le registerAs recoerce explicitement — ne mélange jamais « j'ai validé donc c'est typé » avec un namespace qui shunt la validation.

Quel validateur choisir — Zod vs Joi vs class-validator

CritèreZodJoiclass-validator
Inférence de typez.infer<> natif, source unique❌ types séparés à maintenir⚠️ via la classe, décorateurs verbeux
Coercition string→typez.coerce.number() expliciteJoi.number() coerce par défaut@Type(() => Number) + transform
Cross-champs.superRefine / .refine.when() / Joi.refcustom validators, lourd
Bundle / cold-startléger, tree-shakableplus lourdtire class-transformer
Intégration Nestvalidate: (raw) => schema.parse(raw)validationSchema: natifmanuel
Quand le choisirdéfaut moderne (TS-first)legacy / équipe déjà Joisi déjà tout en DTO décorés

Reco staff : Zod pour un projet TS récent (une seule source de vérité type+runtime, safeParse pour agréger les erreurs). Joi reste parfaitement valable et c'est l'intégration « officielle » historique de Nest (validationSchema). Évite class-validator pour l'env : il brille sur les DTO HTTP, pas sur une map de strings.

🛠️ Code minimal

Setup avec validation Zod

ts
// config/env.schema.ts
import { z } from 'zod';

export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  JWT_TTL: z.string().default('15m'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  STRIPE_KEY: z.string().startsWith('sk_'),
});

export type Env = z.infer<typeof envSchema>;
ts
// config/app.module.ts
import { ConfigModule } from '@nestjs/config';
import { envSchema } from './env.schema';
import databaseConfig from './database.config';
import authConfig from './auth.config';

ConfigModule.forRoot({
  isGlobal: true,
  cache: true,
  envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'],
  load: [databaseConfig, authConfig],
  validate: (raw) => {
    const parsed = envSchema.safeParse(raw);
    if (!parsed.success) {
      // .format() rend un objet imbriqué illisible en log. Aplatis en lignes.
      const issues = parsed.error.issues
        .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
        .join('\n');
      throw new Error(`Config invalide:\n${issues}`);
    }
    return parsed.data; // Nest réinjecte ce retour comme source process.env validée
  },
});

Config namespaced + typée

ts
// config/database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  url: process.env.DATABASE_URL!,
  poolSize: parseInt(process.env.DATABASE_POOL_SIZE ?? '10', 10),
  ssl: process.env.NODE_ENV === 'production',
}));

export type DatabaseConfig = ReturnType<typeof import('./database.config').default>;
ts
// users.module.ts
import { ConfigType } from '@nestjs/config';
import databaseConfig from './config/database.config';

TypeOrmModule.forRootAsync({
  inject: [databaseConfig.KEY],
  useFactory: (cfg: ConfigType<typeof databaseConfig>) => ({
    type: 'postgres',
    url: cfg.url,
    extra: { max: cfg.poolSize },
    ssl: cfg.ssl,
  }),
});

Le ConfigType<typeof databaseConfig> te donne l'autocomplete et empêche cfg.urlz de compiler.

Secrets via SOPS / Vault

bash
# Décrypte au boot (CI/CD ou entrypoint container)
sops -d secrets.enc.yaml > .env.runtime
node dist/main.js

Ou via aws-sdk au runtime :

ts
// config/secrets.provider.ts
@Injectable()
export class SecretsProvider implements OnModuleInit {
  private cache = new Map<string, string>();
  constructor(private readonly ssm: SSMClient) {}

  async onModuleInit() {
    const res = await this.ssm.send(new GetParametersCommand({
      Names: ['/prod/jwt_secret', '/prod/stripe_key'],
      WithDecryption: true,
    }));
    res.Parameters?.forEach(p => this.cache.set(p.Name!, p.Value!));
  }

  get(name: string): string {
    const v = this.cache.get(name);
    if (!v) throw new Error(`Secret missing: ${name}`);
    return v;
  }
}

🎯 Patterns courants

  1. One env file per env.env.development, .env.test, .env.production.example (jamais le vrai prod en repo).
  2. Validate at boot — schema Joi/Zod. Échec = process.exit(1). Pas d'app qui démarre "à moitié configurée".
  3. Namespaced configsregisterAs('database', ...) au lieu d'un mega AppConfig. Évite les imports circulaires et le couplage.
  4. Inject typed config via ConfigType<typeof xxxConfig>, jamais ConfigService.get('database.url') (perte de type, fautes de frappe).
  5. Secrets ≠ config — config = comportement (LOG_LEVEL, FEATURE_X). Secrets = credentials. Les secrets vivent dans Vault/SSM/SOPS, pas en .env committé ni en variables d'env Kubernetes en clair (utilise secretKeyRef).
  6. Hot reload — pour les feature flags : sépare-les dans un service FeatureFlagsService qui poll/sub à Unleash/LaunchDarkly. Pour les vrais secrets en rotation : OnApplicationBootstrap recharge périodiquement.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

  • 7 : @nestjs/[email protected]. ConfigService.get<T>(key) non strict.
  • 8 : @nestjs/config@1. registerAs introduit. validate function ajoutée à côté de validationSchema (Joi).
  • 9 : @nestjs/config@2. ConfigType<> officiellement supporté, forFeature pour scope local.
  • 10 : @nestjs/config@3. expandVariables (interpolation ${VAR} dans .env) activé par option. Drop de Node 14.
  • 11 : @nestjs/config@4. Strict types par défaut sur get, retourne T | undefined sauf si tu passes une default. Brise du code qui faisait cfg.get<string>('FOO') en supposant que ça ne serait jamais undefined. Migration : passe par getOrThrow ou utilise registerAs + ConfigType.

⚠️ Pitfalls

  1. Plusieurs sources qui se contredisent.env.local vs ConfigMap K8s vs env literal du Deployment. Documente l'ordre de priorité, et ajoute un endpoint /debug/config (auth admin) qui dump la config résolue (avec secrets masqués) pour faciliter le diagnostic.
  2. process.env.NODE_ENV lu avant ConfigModule.forRoot — normal, mais alors valide aussi NODE_ENV séparément avant le boot. Sinon un typo ('prod' au lieu de 'production') passe inaperçu et change tout le comportement.
  3. .env committé — secret leak. Ajoute .env* au .gitignore, garde .env.example à jour.
  4. Config lue dans le constructeur d'un provider chargé avant ConfigModule — undefined. Symptôme : ça marche en dev (timing chanceux), crash en prod. Solution : injecter ConfigService, ne pas lire process.env directement.
  5. parseInt sans radix ni defaultPORT=08080 devient 0 en base 8 historiquement. Utilise z.coerce.number() ou Number(x) avec validation.
  6. Cache cache: true + tests — entre suites de test, la config est figée. Pour des tests qui mutent process.env, désactive le cache en test ou utilise ConfigModule.forRoot({ ignoreEnvFile: true, load: [() => ({...})] }).
  7. Booléens via envFEATURE_X=false est la string "false" qui est truthy. Toujours parser : z.enum(['true','false']).transform(v => v === 'true').
  8. Variables qui changent en runtime — env vars sont lues une fois. Si tu veux du dynamique, va vers un store externe (Redis, Consul, LaunchDarkly).
  9. Multi-app, même .env — risque de fuite (le service A voit les secrets du service B). Isole les env vars par service, idéalement par namespace K8s ou par fichier SOPS.

🚦 Boot, probes & observabilité (production)

Le fail-fast n'a de valeur que si l'orchestrateur le voit. Comment un boot raté se propage proprement :

ConfigModule.forRoot validate() throw


  AppModule ne compile pas → main.ts catch → process.exit(1)


  Container exit non-zéro → Pod CrashLoopBackOff


  readinessProbe ne passe JAMAIS ready → le LB n'envoie 0 trafic


  rolling update bloqué → l'ANCIENNE version (saine) reste en service

C'est tout l'intérêt : un déploiement mal configuré échoue de façon visible et sans impact utilisateur, parce que l'ancien ReplicaSet n'est jamais retiré tant que le nouveau n'est pas ready.

ts
// main.ts — le catch qui transforme une config invalide en exit code propre
async function bootstrap() {
  try {
    const app = await NestFactory.create(AppModule, { bufferLogs: true });
    // ... pipes, filters, etc.
    await app.listen(cfg.getOrThrow<number>('PORT'));
  } catch (err) {
    // Log AVANT exit — sinon le message de validation Zod est perdu
    console.error('[boot] fatal config/startup error:', (err as Error).message);
    process.exit(1); // exit non-zéro = signal clair pour K8s/systemd/PM2
  }
}
bootstrap();

get vs getOrThrow vs ConfigType — l'arbre de décision :

BesoinOutilPourquoi
Valeur garantie présente (validée au boot)ConfigType<typeof cfg> injectétype strict, refactor-safe, zéro clé magique
Lecture ponctuelle d'une clé plate validéegetOrThrow<T>('KEY')throw explicite si absente (Nest 11 : get rend T | undefined)
Valeur réellement optionnelleget<T>('KEY') + gardele undefined fait partie du contrat
❌ jamaisprocess.env.X dans le code métiercouplage env, intestable, shunt la validation

Observabilité au boot — logge (en INFO, structuré) un résumé de config résolue au démarrage : NODE_ENV, git sha, modèle LLM actif, feature flags par défaut, fingerprint (hash court) de chaque secret — jamais le secret. Un logger.log({ env: cfg.get('NODE_ENV'), sha: process.env.GIT_SHA, jwtFp: sha256(secret).slice(0,8) }) te permet, en incident, de prouver « le pod tourne bien avec la rotation de secret du 14/06 » sans jamais exposer le secret. Émets aussi une métrique config_loaded{env=...} pour corréler les redéploiements avec les régressions.

Hot reload patterns

Trois sortes de "config dynamique" à distinguer :

  1. Reload via SIGHUP — pattern Unix classique. Tu écoutes le signal, recharges depuis disk/Vault. Limité aux apps qui acceptent les downtime de quelques ms.
  2. PollingsetInterval qui re-read /etc/secrets/... ou Vault. Simple, latence ≈ interval. Pas idéal pour rotation rapide.
  3. Push-based — LaunchDarkly streaming, Consul watch, Kubernetes informer. Latence quasi nulle, plus complexe.

Pour les secrets en rotation (DB password, JWT secret), le pattern recommandé est :

  • Vault rotation via dynamic secrets (lease court, renouvellement auto par le sidecar).
  • L'app ne lit jamais directement Vault ; elle lit un fichier monté par le sidecar (/vault/secrets/db-creds).
  • Sur changement de fichier (chokidar ou fs.watch), recrée le pool DB.

Limitations : les connexions actives doivent être drainées proprement avant de fermer l'ancien pool, sinon erreurs en vol.

🧪 Testing

ts
// test/config.spec.ts
import { ConfigModule } from '@nestjs/config';
import { Test } from '@nestjs/testing';

it('fails fast on missing JWT_SECRET', async () => {
  process.env.JWT_SECRET = '';
  await expect(
    Test.createTestingModule({
      imports: [ConfigModule.forRoot({ validate: /* schema */ })],
    }).compile(),
  ).rejects.toThrow(/JWT_SECRET/);
});
  • Pour les tests d'intégration : override ConfigModule avec un useFactory qui retourne un objet fixe. Pas de .env.test lu silencieusement par les tests de coverage.

🎬 Cas d'usage concrets

Scénario 1 — SaaS de comptabilité multi-environnement

Qui : éditeur d'un SaaS compta utilisé par 800 TPE. Cinq environnements : local, test, ci, staging, production. Chaque env a des intégrations Pennylane et URSSAF différentes (sandbox vs réel). Problème : un dev a poussé en staging un STRIPE_KEY=sk_live_... au lieu de sk_test_... — heureusement intercepté par un script avant le déploiement. Mais la peur s'est installée : il fallait blinder.

ts
// config/env.schema.ts
import { z } from 'zod';

const baseSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'ci', 'staging', 'production']),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.string().url(),
  STRIPE_KEY: z.string().regex(/^sk_(test|live)_/),
  PENNYLANE_API: z.string().url(),
});

export const envSchema = baseSchema.superRefine((env, ctx) => {
  // garde-fou: pas de clés live hors production
  if (env.NODE_ENV !== 'production' && env.STRIPE_KEY.startsWith('sk_live_')) {
    ctx.addIssue({ code: 'custom', path: ['STRIPE_KEY'], message: 'sk_live_* forbidden outside production' });
  }
  if (env.NODE_ENV === 'production' && env.STRIPE_KEY.startsWith('sk_test_')) {
    ctx.addIssue({ code: 'custom', path: ['STRIPE_KEY'], message: 'sk_test_* forbidden in production' });
  }
  if (env.NODE_ENV !== 'production' && !env.PENNYLANE_API.includes('sandbox')) {
    ctx.addIssue({ code: 'custom', path: ['PENNYLANE_API'], message: 'must point to sandbox outside production' });
  }
});

Gains : impossible de démarrer le service avec une mauvaise combinaison clé/env. Le boot fail fast affiche les violations en clair dans les logs CI. Zéro incident similaire depuis 14 mois. Coût de mise en place : un après-midi.

Scénario 2 — FinTech avec secrets management Vault

Qui : néobanque PME en France, ACPR-régulée. Les secrets DB, JWT, partenaires bancaires doivent vivre dans Vault avec rotation automatique (30 jours). Problème : avant Vault, les secrets étaient dans des Secret K8s en base64, partagés via 1Password, parfois committés par erreur. L'audit de sécurité a imposé une rotation + audit trail + impossibilité pour un dev seul d'accéder aux secrets prod.

ts
// config/vault-secrets.provider.ts
@Injectable()
export class VaultSecretsProvider implements OnModuleInit, OnApplicationShutdown {
  private readonly logger = new Logger(VaultSecretsProvider.name);
  private secrets = new Map<string, string>();
  private watcher?: AbortController;

  constructor(private readonly vault: VaultClient) {}

  async onModuleInit() {
    await this.loadAll();
    // watch /vault/secrets/ for file changes (vault-agent sidecar writes there)
    this.watcher = new AbortController();
    fs.watch('/vault/secrets', { signal: this.watcher.signal }, () => this.loadAll());
  }

  async onApplicationShutdown() { this.watcher?.abort(); }

  private async loadAll() {
    const files = await fs.promises.readdir('/vault/secrets');
    for (const f of files) {
      this.secrets.set(f, await fs.promises.readFile(`/vault/secrets/${f}`, 'utf8'));
    }
    this.logger.log({ count: this.secrets.size }, 'secrets reloaded');
  }

  get(name: string): string {
    const v = this.secrets.get(name);
    if (!v) throw new Error(`secret missing: ${name}`);
    return v;
  }
}

Gains : rotation Vault transparente — le sidecar écrit les nouveaux secrets, le watcher recharge, le pool DB est récréé à chaud. Audit trail Vault prouve à l'ACPR qui a accédé à quoi. Les devs ne voient jamais les secrets prod en clair.

Piège prod du fs.watch : sur Linux, fs.watch est basé sur inotify et rate fréquemment les écritures atomiques par rename (le sidecar Vault écrit dans un tmp puis rename() — l'inode change, ton watch suit l'ancien inode et ne voit plus rien). De plus fs.watch émet souvent 2+ events par changement. En prod on durcit ainsi : (1) watch le répertoire, pas le fichier ; (2) debounce le reload (≈ 500 ms) pour coalescer les events ; (3) fallback polling (setInterval 30 s qui compare un hash du contenu) car inotify peut silencieusement décrocher sous charge ou sur certains FS montés ; (4) reload idempotent (ne recrée le pool que si le secret a réellement changé). Sans ces 4 garde-fous, la rotation « marche en staging, échoue en prod » de façon non-déterministe.

Scénario 3 — E-commerce multi-region avec configurations divergentes

Qui : marketplace e-commerce déployée en eu-west-3 (Paris) et eu-central-1 (Francfort) pour la résilience. Configs partiellement partagées, partiellement spécifiques (BDD régionale, CDN, fournisseur SMS local). Problème : copier-coller des .env entre régions = drift garanti. Une variable ajoutée dans la région A n'est pas propagée à la B, l'app B crashe au prochain redéploiement.

ts
// config/region.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('region', () => {
  const region = process.env.AWS_REGION ?? 'eu-west-3';
  const base = {
    region,
    cdn: process.env.CDN_URL!,
    primaryDb: process.env.DATABASE_URL!,
  };
  if (region === 'eu-west-3') {
    return { ...base, smsProvider: 'orange-flexible-storage', smsApiKey: process.env.ORANGE_KEY! };
  }
  if (region === 'eu-central-1') {
    return { ...base, smsProvider: 'vonage', smsApiKey: process.env.VONAGE_KEY! };
  }
  throw new Error(`unsupported region: ${region}`);
});

Gains : un seul code, un seul schéma de validation, deux ConfigMaps K8s séparées (une par région). Les variables manquantes font échouer le boot avant que le LB n'envoie du trafic. La feature flag "active region" pilote le routage DNS — bascule en 30s en cas d'incident régional.

🛠️ Exemple end-to-end

Mise en situation : tu démarres une app SaaS de facturation pour freelances. Tu veux : validation stricte au boot, configs namespacées (database, auth, billing, mail), secrets via fichiers montés (Docker secrets en local, Vault en prod), feature flags via LaunchDarkly, et un endpoint admin /debug/config pour inspecter la config résolue (avec secrets masqués).

ts
// src/config/env.schema.ts
import { z } from 'zod';

export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
  PORT: z.coerce.number().int().default(3000),

  DATABASE_URL: z.string().url(),
  DATABASE_POOL_MAX: z.coerce.number().int().default(20),

  JWT_ACCESS_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
  JWT_ACCESS_TTL: z.string().default('15m'),

  STRIPE_KEY: z.string().regex(/^sk_(test|live)_/),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),

  SMTP_HOST: z.string(),
  SMTP_USER: z.string(),
  SMTP_PASS: z.string(),
  MAIL_FROM: z.string().email(),

  LD_SDK_KEY: z.string().startsWith('sdk-'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export type Env = z.infer<typeof envSchema>;
ts
// src/config/database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  url: process.env.DATABASE_URL!,
  poolMax: Number(process.env.DATABASE_POOL_MAX ?? 20),
  ssl: process.env.NODE_ENV === 'production',
}));
ts
// src/config/billing.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('billing', () => ({
  stripe: {
    secretKey: process.env.STRIPE_KEY!,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  },
  trial: { days: 14, currency: 'EUR' },
}));
ts
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { readFileSync, existsSync } from 'node:fs';
import { envSchema } from './config/env.schema';
import databaseConfig from './config/database.config';
import authConfig from './config/auth.config';
import billingConfig from './config/billing.config';
import mailConfig from './config/mail.config';

function loadSecretFiles() {
  // Docker / Vault sidecar pattern: /run/secrets/<NAME> overrides process.env[NAME]
  const dir = '/run/secrets';
  if (!existsSync(dir)) return;
  for (const key of ['JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET', 'STRIPE_KEY', 'STRIPE_WEBHOOK_SECRET', 'SMTP_PASS']) {
    const path = `${dir}/${key}`;
    if (existsSync(path)) process.env[key] = readFileSync(path, 'utf8').trim();
  }
}

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'],
      load: [databaseConfig, authConfig, billingConfig, mailConfig, () => { loadSecretFiles(); return {}; }],
      validate: (raw) => {
        const parsed = envSchema.safeParse(raw);
        if (!parsed.success) {
          const issues = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n');
          throw new Error(`Invalid configuration:\n${issues}`);
        }
        return parsed.data;
      },
    }),
  ],
})
export class AppModule {}
ts
// src/admin/debug-config.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AdminGuard } from '../auth/admin.guard';

@UseGuards(AdminGuard)
@Controller('debug')
export class DebugConfigController {
  constructor(private readonly cfg: ConfigService) {}

  @Get('config')
  dump() {
    // ⚠️ `internalConfig` est un champ PRIVÉ non documenté de ConfigService — il peut
    // disparaître à toute montée de version (@nestjs/config@4 l'a déjà renommé en interne).
    // Senior move : ne dépends PAS de l'interne. Maintiens une liste EXPLICITE des clés à
    // exposer, et lis-les via getOrThrow. Plus verbeux, mais stable et auditable.
    const EXPOSED = ['NODE_ENV', 'PORT', 'LOG_LEVEL', 'database.url', 'database.poolMax'] as const;
    const out: Record<string, unknown> = {};
    for (const k of EXPOSED) out[k] = this.cfg.get(k);
    return mask(out);
  }
}

function mask(obj: unknown): unknown {
  if (typeof obj !== 'object' || obj === null) return obj;
  const SENSITIVE = /secret|password|key|token/i;
  return Object.fromEntries(
    Object.entries(obj as Record<string, unknown>).map(([k, v]) => {
      if (SENSITIVE.test(k) && typeof v === 'string') return [k, `${v.slice(0, 4)}...***`];
      if (typeof v === 'object' && v !== null) return [k, mask(v)];
      return [k, v];
    }),
  );
}
ts
// src/feature-flags/feature-flags.service.ts
import { Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import * as LaunchDarkly from 'launchdarkly-node-server-sdk';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class FeatureFlagsService implements OnModuleInit, OnApplicationShutdown {
  private client!: LaunchDarkly.LDClient;
  constructor(private readonly cfg: ConfigService) {}

  async onModuleInit() {
    this.client = LaunchDarkly.init(this.cfg.getOrThrow<string>('LD_SDK_KEY'));
    await this.client.waitForInitialization();
  }
  async onApplicationShutdown() { await this.client.close(); }

  isEnabled(flag: string, userId: string, def = false): Promise<boolean> {
    return this.client.variation(flag, { key: userId }, def);
  }
}

Effets concrets : un JWT_ACCESS_SECRET court (< 32 chars) fait crasher le boot avec un message lisible avant que le premier user ne s'authentifie. La rotation Stripe (sk_test_...sk_live_... quand on passe en prod) est validée par le schema — pas de fuite. L'admin peut inspecter /debug/config quand un client signale "il manque le SMTP" sans devoir SSH la prod. Le feature flag billing.invoice.v2 pilote l'activation progressive de la nouvelle facturation par tenant — sans redéploiement.

🔁 Quand utiliser / éviter

  • .env + validation : par défaut, toujours. Coût minimal, valeur énorme (fail fast).
  • Secrets manager (Vault/SSM/SOPS) : dès staging/prod, ou dès qu'un secret est partagé entre devs.
  • Dynamic config store (LaunchDarkly, Unleash, Consul) : feature flags, kill switches, A/B. Pas pour les credentials.
  • Évite dotenv direct dans le code Nest — utilise @nestjs/config, sinon tu doublonnes le chargement et perds la validation.
  • Évite process.env dans le code métier — couplage à l'env, intestable. Toujours via DI (ConfigService ou ConfigType<>).

Multi-env strategy

Une matrice claire :

EnvSource configSecrets storeHot reloadDebug
local.env.local.env.localnodemonyes
test.env.testhardcoded fixturesnono
ciinjected by runnerrepository secretsnono
stagingK8s ConfigMapExternalSecrets→Vaultrollingyes
productionK8s ConfigMapExternalSecrets→Vaultrollingno

Pas de .env.staging.example qui dérive — la source de vérité est le manifest K8s (versionné, reviewable). Le .env.example ne sert qu'au dev local.

Dynamic config — feature flags

Pour ce qui change sans redéploiement (kill switch, % rollout, A/B) :

ts
@Injectable()
export class FeatureFlagsService implements OnModuleInit, OnApplicationShutdown {
  private client: LDClient;

  async onModuleInit() {
    this.client = LaunchDarkly.init(process.env.LD_SDK_KEY!);
    await this.client.waitForInitialization();
  }

  isEnabled(flag: string, user: { id: string }, defaultValue = false): Promise<boolean> {
    return this.client.variation(flag, { key: user.id }, defaultValue);
  }

  async onApplicationShutdown() { await this.client.close(); }
}

Règle d'or : un feature flag a une durée de vie limitée. Une fois la feature stable, on retire le flag (et le code "off"). Sinon le codebase devient un sapin de Noël ingouvernable.

🤖 Config pour servir des agents IA (stack Anthropic)

Quand ton NestJS sert ou orchestre un LLM, la config devient critique : un mauvais model id ou une clé sk_live/sk_test mélangée coûte de l'argent réel à chaque token. Le pattern senior : le SDK n'est jamais instancié dans un champ (new Anthropic() interdit) — il est configuré et injecté via registerAs + forFeature, exactement comme une base de données.

ts
// config/llm.config.ts
import { registerAs } from '@nestjs/config';

// IDs Anthropic actuels — versionnés en config, jamais en dur dans le service.
// Flagship: claude-opus-4-8 | équilibré: claude-sonnet-4-6 | rapide/cheap: claude-haiku-4-5
export default registerAs('llm', () => ({
  apiKey: process.env.ANTHROPIC_API_KEY!,
  model: process.env.LLM_MODEL ?? 'claude-sonnet-4-6',
  fastModel: process.env.LLM_FAST_MODEL ?? 'claude-haiku-4-5',
  maxTokens: Number(process.env.LLM_MAX_TOKENS ?? 1024),
  // garde-fou coût : plafond € / heure imposé au edge (voir cost-guard plus bas)
  costCeilingEurPerHour: Number(process.env.LLM_COST_CEILING_EUR ?? 5),
  // SDK retries (le SDK Anthropic retry déjà les 429/5xx avec backoff)
  maxRetries: Number(process.env.LLM_MAX_RETRIES ?? 2),
}));

Étends le schéma Zod pour que le boot fail fast sur une config IA invalide (un model id typé évite de découvrir model: "claude-opus" — inexistant — au premier appel facturé) :

ts
// dans env.schema.ts
const llmEnv = z.object({
  ANTHROPIC_API_KEY: z.string().startsWith('sk-ant-'),
  LLM_MODEL: z
    .enum(['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'])
    .default('claude-sonnet-4-6'),
  LLM_MAX_TOKENS: z.coerce.number().int().positive().max(8192).default(1024),
  LLM_COST_CEILING_EUR: z.coerce.number().positive().default(5),
});

Le client SDK est fourni par forRootAsync (DI testable, mockable, un seul point de config) :

ts
// llm/llm.module.ts
import { Module } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ConfigType } from '@nestjs/config';
import llmConfig from '../config/llm.config';

export const ANTHROPIC = Symbol('ANTHROPIC');

@Module({
  providers: [
    {
      provide: ANTHROPIC,
      inject: [llmConfig.KEY],
      // injecté, pas `new Anthropic()` dans un champ → testable + une seule source de vérité
      useFactory: (cfg: ConfigType<typeof llmConfig>) =>
        new Anthropic({ apiKey: cfg.apiKey, maxRetries: cfg.maxRetries }),
    },
  ],
  exports: [ANTHROPIC],
})
export class LlmModule {}

Pourquoi ça relève de la config (pas du hardcode) :

PréoccupationMauvaisPattern senior
Model id'claude-opus-4-8' en dur dans 30 servicescfg.model, switchable par env sans redéploiement de code
Clé APInew Anthropic() lit ANTHROPIC_API_KEY implicitementinjectée via DI → mockable en test, validée au boot
Routage par envmême modèle partoutclaude-haiku-4-5 en dev (cheap), claude-opus-4-8 en prod (qualité) via LLM_MODEL
Garde-fou coûtaucun → facture surprisecostCeilingEurPerHour lu au edge, kill-switch via feature flag
Kill switchredéploiementflag LaunchDarkly llm.enabled → coupe l'IA sans redéployer

Cost-guard au edge — un compteur de coût par fenêtre glissante, alimenté par la config, qui rejette (HTTP 429) avant d'appeler le LLM si le plafond est dépassé. Couplé à un feature flag llm.enabled (kill switch instantané) et à un rate-limit par tenant. Ce trio — config validée + flag dynamique + cost-guard — est ce qui empêche une boucle agentique buguée de brûler 4 000 € en une nuit. Pour le streaming SSE/WebSocket, l'AbortController sur déconnexion client, et la boucle tool-use serveur, voir le fichier dédié aux agents ; ici on retient que tous leurs paramètres (modèle, plafond, timeouts, flags) vivent dans la config validée au boot, jamais en dur.

🏋️ Exercices

1. Fail-fast schema (échauffement)

Objectif : écrire un schéma Zod qui refuse de booter si JWT_SECRET < 32 chars, si STRIPE_KEY est sk_live_* hors production, ou si PORT n'est pas un entier valide — avec un message d'erreur agrégé et lisible (toutes les violations d'un coup, pas la première).

Indice/Solution : safeParse puis parsed.error.issues.map(i => \${i.path.join('.')}: ${i.message}`).join('\n'). Le garde-fou cross-champs (sk_livevsNODE_ENV) se fait via .superRefine((env, ctx) => ctx.addIssue(...))`, pas dans un champ isolé.

2. Précédence & override (compréhension)

Objectif : prouver par un test que process.env.PORT=4000 (exporté shell) gagne sur PORT=3000 dans .env, et que l'inverse n'est pas vrai. Puis écrire un .env avec BASE=https://api.local et WEBHOOK=${BASE}/hook qui résout correctement.

Indice/Solution : dotenv ne surcharge pas une clé déjà dans process.env. Pour l'interpolation ${VAR}, active expandVariables: true dans forRoot (option @nestjs/config@10+). Test : process.env.PORT='4000' avant Test.createTestingModule, assert cfg.get('PORT') === 4000.

3. Config IA injectée + mockée (production-grade)

Objectif : refactorer un service qui fait new Anthropic() dans un champ vers un client injecté via forRootAsync + registerAs('llm'). Écrire un test unitaire qui override le provider ANTHROPIC par un mock retournant un token fixe, sans toucher au réseau ni à une clé réelle.

Indice/Solution : Test.createTestingModule({...}).overrideProvider(ANTHROPIC).useValue({ messages: { create: jest.fn().mockResolvedValue(...) } }). Valide aussi que LLM_MODEL invalide ('claude-opus') fait échouer le boot via le z.enum.

4. Hot-reload de secret robuste (production-grade)

Objectif : implémenter un SecretsProvider qui watch /run/secrets, recharge sur changement, recrée le pool DB — mais survit aux écritures atomiques par rename, aux events dupliqués, et au décrochage d'inotify.

Indice/Solution : watch le dossier (pas le fichier), debounce ≈ 500 ms, compare un hash du contenu (reload idempotent : ne recrée le pool que si le secret a changé), et ajoute un fallback polling 30 s. Draine l'ancien pool (await oldPool.end() après que les requêtes en vol soient terminées) avant de le remplacer.

5. Break it then fix it (debug sous pression)

Objectif : on te donne une app où FEATURE_NEW_BILLING=false active quand même la nouvelle facturation, et où cfg.get('SSL') est true en dev. Diagnostique et corrige les deux bugs.

Indice/Solution : la string "false" est truthy → parser via z.enum(['true','false']).transform(v => v === 'true') (ou z.coerce.boolean() est un piège : Boolean("false") === true). Le SSL vient d'un registerAs qui lit process.env.SSL brut (string) au lieu du champ validé/coercé — déplace la coercition dans le schéma.

6. Audit de fuite (sécurité, niveau staff)

Objectif : écrire un test (ou un check CI) qui échoue si l'endpoint /debug/config expose un secret en clair, ou si un secret apparaît dans les logs au boot. Bonus : interdire process.env partout sauf dans config/.

Indice/Solution : pour le masque, assert que toute valeur dont la clé matche /secret|password|key|token/i est tronquée. Pour les logs : un Logger custom qui scrub les patterns de secrets connus avant émission. Pour le lint : règle ESLint no-restricted-properties / no-process-env avec un override autorisant uniquement src/config/**.

🎤 En entretien

Q : Pourquoi valider la config au boot plutôt que de laisser le code planter à l'usage ? R : Fail-fast déplace l'erreur du runtime sous trafic (3 h du matin, astreinte réveillée, un secret manquant découvert au 1er appel facturé) vers le démarrage (le pod ne passe jamais ready, le rolling update s'arrête, le LB n'envoie aucun trafic). Le coût d'une mauvaise config devient un déploiement échoué visible, pas un incident de prod.

Q : ConfigService.get('db.url') vs ConfigType<typeof dbConfig> injecté — lequel et pourquoi ? R : ConfigType à chaque fois que possible : type strict (un typo cfg.urlz ne compile pas), pas de clé string magique, refactor-safe. ConfigService.get perd le type et tolère les fautes de frappe silencieuses ; en Nest 11 il retourne T | undefined par défaut, donc on tombe sur getOrThrow qui est verbeux. registerAs + ConfigType namespace proprement et évite le couplage à un mega-AppConfig.

Q : Où vivent les secrets, et pourquoi pas dans les variables d'env Kubernetes ? R : Les Secret K8s sont du base64, pas du chiffrement — lisibles par quiconque a accès get secrets au namespace, et souvent loggués/exposés. Les vrais secrets vivent dans Vault/SSM/SOPS avec rotation, lease court et audit trail ; l'app lit un fichier monté par un sidecar (/vault/secrets/...), jamais Vault directement. On distingue config (comportement : LOG_LEVEL, feature flags) de secrets (credentials) : sources, cycle de vie et contrôle d'accès différents.

Q : Tu sers un LLM Anthropic depuis NestJS. Qu'est-ce qui doit absolument être en config validée, et pas en dur ? R : Le model id (claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5), la clé API (injectée via DI, pas new Anthropic() dans un champ → testable/mockable), le plafond de coût par fenêtre, les timeouts, et un kill-switch via feature flag. Raison : un mauvais model id ou une boucle agentique buguée coûte de l'argent réel par token ; la config validée au boot + cost-guard au edge + flag dynamique permet de couper ou switcher de modèle sans redéployer, et le z.enum sur le model id transforme une facture surprise en échec de boot lisible.

Q : Ton validate retourne un objet ; pourtant cfg.get('database.url') (namespace) n'est pas coercé. Pourquoi, et comment tu évites le bug ? R : validate(raw) s'exécute sur la map brute process.env et son retour remplace la source des clés plates seulement. Les factories registerAs tournent après et relisent process.env directement (strings non coercées) — d'où une string "3000" côté namespace alors que cfg.get('PORT') plat rend 3000 (number). Fix staff : ou bien tout passe par les clés plates validées, ou bien chaque registerAs recoerce explicitement (Number(...), === 'production') et n'assume jamais que « c'est déjà validé ». Ne jamais mixer les deux mondes sur la même variable.

Q : Pourquoi un boot qui throw est meilleur qu'un fallback sur valeur par défaut, en prod sous Kubernetes ? R : Un throwprocess.exit(1) → exit non-zéro → CrashLoopBackOff → la readinessProbe ne passe jamais ready → le rolling update se bloque et garde l'ancienne version saine en service, zéro trafic vers le pod cassé. Un fallback silencieux, lui, démarre un pod à moitié configuré qui passe ready, prend du trafic, et corrompt des données avant qu'on comprenne. Fail-fast convertit un incident de prod en déploiement échoué visible et sans impact.

🔗 Liens

Bibliothèque tech perso — Achref