Gestion centralisée de la configuration dans NestJS

Gestion centralisée de la configuration dans NestJS

Dans toute application backend sérieuse, la gestion de la configuration est un sujet critique. Entre les variables d'environnement, les secrets, les paramètres spécifiques à chaque environnement (développement, staging, production) et les valeurs par défaut, il est facile de se retrouver avec une configuration éparpillée et difficile à maintenir. NestJS propose une approche élégante via le package @nestjs/config, permettant de centraliser, typer et valider toute la configuration de l'application. Dans ce tutoriel, nous allons voir comment mettre en place une gestion robuste de la configuration, en s'appuyant sur un cas réel issu d'une application de production.

Prérequis

  • Une application NestJS fonctionnelle (version 10+)
  • Node.js 18 ou supérieur
  • Des notions de base sur les variables d'environnement et le fichier .env

Installation de @nestjs/config

Le package @nestjs/config repose sur la librairie dotenv pour charger les variables d'environnement. Il étend ce mécanisme avec un module injectable et des fonctionnalités avancées comme la validation de schéma et le namespacing.

npm install @nestjs/config

Pour la validation de schéma (que nous verrons plus loin), Joi est l'outil de prédilection :

npm install joi

Mise en place du ConfigModule global

La première étape consiste à enregistrer le ConfigModule dans le module racine de l'application. L'option isGlobal: true évite d'avoir à réimporter ce module dans chaque feature module : le ConfigService devient alors injectable partout.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
    }),
    // ... autres modules
  ],
})
export class AppModule {}

L'option load est la clé de cette approche centralisée : elle accepte un tableau de fonctions qui retournent un objet de configuration. Cela permet de structurer la configuration en namespaces logiques (database, jwt, email, etc.) plutôt que de manipuler des variables plates.

Créer un fichier de configuration typé

Plutôt que d'éparpiller des appels à process.env.X dans tout le code, on regroupe toute la configuration dans un fichier configuration.ts. Ce fichier exporte une fonction qui retourne un objet structuré, avec des conversions de types et des valeurs par défaut.

Voici un exemple complet, inspiré d'une application de production :

// src/config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT || '3002', 10),
  frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
  cors: {
    origin: process.env.CORS_ORIGINS
      ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
      : ['http://localhost:3000', 'http://localhost:3004'],
  },
  database: {
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT || '5432', 10),
    username: process.env.DB_USERNAME || 'postgres',
    password: process.env.DB_PASSWORD || 'postgres',
    name: process.env.DB_NAME || 'voyance',
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '30m',
  },
  email: {
    host: process.env.EMAIL_HOST || 'smtp.gmail.com',
    port: parseInt(process.env.EMAIL_PORT || '587', 10),
    secure: process.env.EMAIL_SECURE === 'true',
    user: process.env.EMAIL_USER,
    password: process.env.EMAIL_PASSWORD,
    from: process.env.EMAIL_FROM || 'Contact <contact@example.com>',
  },
  upload: {
    destination: process.env.UPLOAD_DEST || './uploads',
    maxFileSize: parseInt(process.env.UPLOAD_MAX_SIZE || '5242880', 10),
  },
  stripe: {
    secretKey: process.env.STRIPE_SECRET_KEY,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  },
});

Plusieurs bonnes pratiques sont à noter ici :

  • Conversion explicite des types : parseInt pour les nombres, comparaison === 'true' pour les booléens. Les variables d'environnement sont toujours des chaînes de caractères.
  • Valeurs par défaut sensées pour le développement local, ce qui simplifie l'onboarding des développeurs.
  • Regroupement par domaine (database, jwt, email) qui rend la configuration lisible et autodocumentée.
  • Parsing de listes via split pour les variables multivaluées comme CORS_ORIGINS.

Validation au démarrage

Une configuration invalide doit faire échouer l'application le plus tôt possible, idéalement au démarrage. Plutôt que de découvrir en pleine production qu'une variable critique manque, on valide tout au boot.

Une approche simple consiste à exécuter une fonction de validation directement dans configuration.ts :

function validateEnv() {
  const isProduction = process.env.NODE_ENV === 'production';
  const required: string[] = ['JWT_SECRET'];

  if (isProduction) {
    required.push('DB_HOST', 'DB_PASSWORD', 'FRONTEND_URL');
  }

  const missing = required.filter((key) => !process.env[key]);
  if (missing.length > 0) {
    throw new Error(
      `Variables d'environnement manquantes : ${missing.join(', ')}. L'application ne peut pas démarrer.`,
    );
  }
}

validateEnv();

export default () => ({ /* ... */ });

Cette fonction est appelée dès le chargement du module : si une variable critique est absente, NestJS lèvera une exception explicite avant même d'initialiser les services. Notez qu'on adapte les exigences selon l'environnement : en développement, on peut tolérer des valeurs par défaut, mais en production, certaines variables sont obligatoires.

Validation avancée avec Joi

Pour une validation plus fine (types, formats, contraintes), Joi est intégré nativement à @nestjs/config :

import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  load: [configuration],
  validationSchema: Joi.object({
    NODE_ENV: Joi.string()
      .valid('development', 'production', 'test')
      .default('development'),
    PORT: Joi.number().default(3002),
    JWT_SECRET: Joi.string().min(32).required(),
    JWT_EXPIRES_IN: Joi.string().default('30m'),
    DB_HOST: Joi.string().required(),
    DB_PORT: Joi.number().default(5432),
    DB_PASSWORD: Joi.string().required(),
  }),
  validationOptions: {
    abortEarly: false, // Affiche toutes les erreurs d'un coup
  },
}),

L'avantage de Joi est qu'il valide chaque variable avec ses contraintes (longueur minimale du secret JWT, format numérique du port, etc.) et liste toutes les erreurs d'un coup avec abortEarly: false.

Accéder à la configuration via ConfigService

Une fois le module configuré, on injecte ConfigService dans n'importe quel provider. La méthode get est typée et supporte la notation pointée pour accéder aux propriétés imbriquées :

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class DatabaseService {
  constructor(private readonly configService: ConfigService) {}

  getConnectionInfo() {
    const host = this.configService.get<string>('database.host');
    const port = this.configService.get<number>('database.port');
    const password = this.configService.get<string>('database.password');

    return { host, port, password };
  }
}

On peut également récupérer un namespace entier :

const dbConfig = this.configService.get('database');
// { host, port, username, password, name }

Pour les valeurs critiques, utilisez getOrThrow qui lève une exception si la valeur est undefined :

const jwtSecret = this.configService.getOrThrow<string>('jwt.secret');

Variables par environnement

En pratique, on dispose de plusieurs fichiers .env selon le contexte :

  • .env : valeurs par défaut, partagées entre tous les développeurs (sans secrets)
  • .env.development : configuration locale de dev
  • .env.test : configuration pour les tests automatisés
  • .env.production : configuration de production (souvent absente du repo)

NestJS permet de spécifier le fichier à charger selon NODE_ENV :

ConfigModule.forRoot({
  isGlobal: true,
  load: [configuration],
  envFilePath: [
    `.env.${process.env.NODE_ENV || 'development'}`,
    '.env',
  ],
}),

L'ordre du tableau est important : le premier fichier trouvé prime, ce qui permet de définir des valeurs spécifiques par environnement tout en gardant des défauts communs dans .env.

Sécurité : ne jamais commiter les secrets

Cette règle est non négociable. Les fichiers .env contenant des secrets (mots de passe, clés API, secrets JWT) ne doivent jamais être versionnés. Ajoutez-les à votre .gitignore :

.env
.env.local
.env.*.local
.env.production
.env.development

En revanche, fournissez un fichier .env.example dans le repo, listant toutes les variables nécessaires sans leurs valeurs réelles :

# .env.example
NODE_ENV=development
PORT=3002

# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=
DB_NAME=mydb

# JWT
JWT_SECRET=
JWT_EXPIRES_IN=30m

# Email
EMAIL_HOST=smtp.gmail.com
EMAIL_USER=
EMAIL_PASSWORD=

Ce fichier sert de documentation vivante : tout nouveau développeur n'a qu'à le copier en .env et remplir les valeurs.

Configuration en production : Docker et cloud

En production, on n'utilise généralement pas de fichier .env. Les variables d'environnement sont injectées par l'orchestrateur (Docker, Kubernetes) ou la plateforme cloud (Heroku, AWS, GCP, Vercel).

Avec Docker, on les passe via docker-compose.yml ou un fichier d'environnement séparé :

services:
  api:
    image: my-nestjs-app
    environment:
      NODE_ENV: production
      PORT: 3002
      DB_HOST: postgres
      JWT_SECRET: ${JWT_SECRET}
    env_file:
      - .env.production

Sur Kubernetes, les secrets sont gérés via des objets Secret et injectés dans les pods. Sur AWS, on utilise SSM Parameter Store ou Secrets Manager. Le principe reste le même : le code de l'application n'a pas à savoir d'où viennent les variables, il les lit simplement via ConfigService.

Conclusion

Une bonne gestion de la configuration repose sur quelques principes simples mais fondamentaux : centraliser dans un fichier configuration.ts typé, valider au démarrage, séparer les environnements, et ne jamais commiter de secrets. @nestjs/config fournit tous les outils nécessaires pour appliquer ces principes proprement, avec en bonus une intégration native à l'écosystème NestJS via l'injection de dépendances.

Pour aller plus loin, vous pouvez explorer les configurations namespacées avec registerAs(), qui permettent un typage encore plus fort, ainsi que l'intégration avec des secret managers cloud comme AWS Secrets Manager ou HashiCorp Vault pour une gestion encore plus sécurisée des secrets en production.

Continuer la lecture

Article suivant — NestJS Créer des endpoints de seed pour initialiser les données Explorer tout : NestJS

Commentaires

Soyez le premier à laisser un commentaire — le robot attend.

Laisser un commentaire

Les champs obligatoires sont indiqués avec *