Configuration & environnement
TL;DR —
@nestjs/configcharge.env,process.env, et expose unConfigServicetypé. Le vrai senior move : valider au boot (Joi/Zod), namespacer par feature, interdireprocess.envpartout 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 FeatureFlagsModuleAnalogie : 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 :
validateretourneparsed.dataavecPORT: 3000(number), maisregisterAs('http', () => process.env.PORT)relitprocess.env.PORT="3000"(string). Les deux coexistent dans le mêmeConfigService. Règle staff : soit tout passe par le schéma validé (clés plates), soit leregisterAsrecoerce 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ère | Zod | Joi | class-validator |
|---|---|---|---|
| Inférence de type | ✅ z.infer<> natif, source unique | ❌ types séparés à maintenir | ⚠️ via la classe, décorateurs verbeux |
| Coercition string→type | z.coerce.number() explicite | Joi.number() coerce par défaut | @Type(() => Number) + transform |
| Cross-champs | .superRefine / .refine | .when() / Joi.ref | custom validators, lourd |
| Bundle / cold-start | léger, tree-shakable | plus lourd | tire class-transformer |
| Intégration Nest | validate: (raw) => schema.parse(raw) | validationSchema: natif | manuel |
| Quand le choisir | défaut moderne (TS-first) | legacy / équipe déjà Joi | si 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
// 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>;// 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
// 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>;// 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êchecfg.urlzde compiler.
Secrets via SOPS / Vault
# Décrypte au boot (CI/CD ou entrypoint container)
sops -d secrets.enc.yaml > .env.runtime
node dist/main.jsOu via aws-sdk au runtime :
// 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
- One env file per env —
.env.development,.env.test,.env.production.example(jamais le vrai prod en repo). - Validate at boot — schema Joi/Zod. Échec = process.exit(1). Pas d'app qui démarre "à moitié configurée".
- Namespaced configs —
registerAs('database', ...)au lieu d'un megaAppConfig. Évite les imports circulaires et le couplage. - Inject typed config via
ConfigType<typeof xxxConfig>, jamaisConfigService.get('database.url')(perte de type, fautes de frappe). - Secrets ≠ config — config = comportement (LOG_LEVEL, FEATURE_X). Secrets = credentials. Les secrets vivent dans Vault/SSM/SOPS, pas en
.envcommitté ni en variables d'env Kubernetes en clair (utilisesecretKeyRef). - Hot reload — pour les feature flags : sépare-les dans un service
FeatureFlagsServicequi poll/sub à Unleash/LaunchDarkly. Pour les vrais secrets en rotation :OnApplicationBootstraprecharge périodiquement.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- 7 :
@nestjs/[email protected].ConfigService.get<T>(key)non strict. - 8 :
@nestjs/config@1.registerAsintroduit.validatefunction ajoutée à côté devalidationSchema(Joi). - 9 :
@nestjs/config@2.ConfigType<>officiellement supporté,forFeaturepour 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 surget, retourneT | undefinedsauf si tu passes une default. Brise du code qui faisaitcfg.get<string>('FOO')en supposant que ça ne serait jamais undefined. Migration : passe pargetOrThrowou utiliseregisterAs+ConfigType.
⚠️ Pitfalls
- Plusieurs sources qui se contredisent —
.env.localvs 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. process.env.NODE_ENVlu avantConfigModule.forRoot— normal, mais alors valide aussiNODE_ENVséparément avant le boot. Sinon un typo ('prod'au lieu de'production') passe inaperçu et change tout le comportement..envcommitté — secret leak. Ajoute.env*au.gitignore, garde.env.exampleà jour.- Config lue dans le constructeur d'un provider chargé avant
ConfigModule— undefined. Symptôme : ça marche en dev (timing chanceux), crash en prod. Solution : injecterConfigService, ne pas lireprocess.envdirectement. parseIntsans radix ni default —PORT=08080devient0en base 8 historiquement. Utilisez.coerce.number()ouNumber(x)avec validation.- Cache
cache: true+ tests — entre suites de test, la config est figée. Pour des tests qui mutentprocess.env, désactive le cache en test ou utiliseConfigModule.forRoot({ ignoreEnvFile: true, load: [() => ({...})] }). - Booléens via env —
FEATURE_X=falseest la string"false"qui est truthy. Toujours parser :z.enum(['true','false']).transform(v => v === 'true'). - Variables qui changent en runtime — env vars sont lues une fois. Si tu veux du dynamique, va vers un store externe (Redis, Consul, LaunchDarkly).
- 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 serviceC'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.
// 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 :
| Besoin | Outil | Pourquoi |
|---|---|---|
| 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ée | getOrThrow<T>('KEY') | throw explicite si absente (Nest 11 : get rend T | undefined) |
| Valeur réellement optionnelle | get<T>('KEY') + garde | le undefined fait partie du contrat |
| ❌ jamais | process.env.X dans le code métier | couplage 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 :
- Reload via SIGHUP — pattern Unix classique. Tu écoutes le signal, recharges depuis disk/Vault. Limité aux apps qui acceptent les downtime de quelques ms.
- Polling —
setIntervalqui re-read/etc/secrets/...ou Vault. Simple, latence ≈ interval. Pas idéal pour rotation rapide. - 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 (
chokidaroufs.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
// 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
ConfigModuleavec unuseFactoryqui retourne un objet fixe. Pas de.env.testlu 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.
// 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.
// 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.watchest basé surinotifyet rate fréquemment les écritures atomiques par rename (le sidecar Vault écrit dans un tmp puisrename()— l'inode change, ton watch suit l'ancien inode et ne voit plus rien). De plusfs.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 (setInterval30 s qui compare un hash du contenu) carinotifypeut 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.
// 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).
// 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>;// 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',
}));// 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' },
}));// 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 {}// 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];
}),
);
}// 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
dotenvdirect dans le code Nest — utilise@nestjs/config, sinon tu doublonnes le chargement et perds la validation. - Évite
process.envdans le code métier — couplage à l'env, intestable. Toujours via DI (ConfigServiceouConfigType<>).
Multi-env strategy
Une matrice claire :
| Env | Source config | Secrets store | Hot reload | Debug |
|---|---|---|---|---|
| local | .env.local | .env.local | nodemon | yes |
| test | .env.test | hardcoded fixtures | no | no |
| ci | injected by runner | repository secrets | no | no |
| staging | K8s ConfigMap | ExternalSecrets→Vault | rolling | yes |
| production | K8s ConfigMap | ExternalSecrets→Vault | rolling | no |
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) :
@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.
// 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é) :
// 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) :
// 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éoccupation | Mauvais | Pattern senior |
|---|---|---|
| Model id | 'claude-opus-4-8' en dur dans 30 services | cfg.model, switchable par env sans redéploiement de code |
| Clé API | new Anthropic() lit ANTHROPIC_API_KEY implicitement | injectée via DI → mockable en test, validée au boot |
| Routage par env | même modèle partout | claude-haiku-4-5 en dev (cheap), claude-opus-4-8 en prod (qualité) via LLM_MODEL |
| Garde-fou coût | aucun → facture surprise | costCeilingEurPerHour lu au edge, kill-switch via feature flag |
| Kill switch | redéploiement | flag 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 throw → process.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.