NestJS – Config Module & Validation biến môi trường

 

Quản lý configuration đúng cách là nền tảng của mọi ứng dụng production-ready. NestJS @nestjs/config kết hợp class-validator giúp validate env variables ngay khi khởi động — app sẽ crash ngay nếu thiếu config thay vì lỗi runtime.

1. Cài đặt

npm install @nestjs/config joi
# Hoặc dùng class-validator thay cho joi
npm install @nestjs/config class-validator class-transformer

2. Cấu hình cơ bản

// app.module.ts
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,       // Không cần import ở từng module
      envFilePath: [
        `.env.${process.env.NODE_ENV ?? 'development'}`, // .env.development, .env.production
        '.env',             // Fallback
      ],
      cache: true,          // Cache giá trị — tránh đọc lại nhiều lần
      expandVariables: true, // Hỗ trợ ${OTHER_VAR} trong .env
    }),
  ],
})
export class AppModule {}
# .env.development
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-not-for-prod

# .env.production
NODE_ENV=production
PORT=8080
DB_HOST=${RDS_HOSTNAME}
DB_PORT=5432
DB_NAME=mydb_prod
REDIS_URL=${ELASTICACHE_URL}
JWT_SECRET=${JWT_SECRET_FROM_SSM}

3. Validate với Joi

import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
    PORT: Joi.number().default(3000),

    // Database
    DB_HOST: Joi.string().required(),
    DB_PORT: Joi.number().default(5432),
    DB_USER: Joi.string().required(),
    DB_PASSWORD: Joi.string().required(),
    DB_NAME: Joi.string().required(),

    // Redis
    REDIS_URL: Joi.string().uri().required(),

    // JWT
    JWT_SECRET: Joi.string().min(32).required(),
    JWT_EXPIRES_IN: Joi.string().default('7d'),

    // AWS (optional)
    AWS_ACCESS_KEY_ID: Joi.string().when('NODE_ENV', {
      is: 'production',
      then: Joi.required(),
    }),
    AWS_SECRET_ACCESS_KEY: Joi.string().when('NODE_ENV', {
      is: 'production',
      then: Joi.required(),
    }),
  }),
  validationOptions: {
    allowUnknown: true,  // Cho phép env vars không trong schema
    abortEarly: false,   // Hiển thị tất cả lỗi một lúc
  },
}),

Khi thiếu DB_HOST, app không start được:

Error: Config validation error:
  "DB_HOST" is required
  "JWT_SECRET" length must be at least 32 characters long

4. Type-safe Config với namespace

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

export const databaseConfig = registerAs('database', () => ({
  host: process.env.DB_HOST!,
  port: parseInt(process.env.DB_PORT ?? '5432', 10),
  username: process.env.DB_USER!,
  password: process.env.DB_PASSWORD!,
  name: process.env.DB_NAME!,
  ssl: process.env.NODE_ENV === 'production',
  poolSize: parseInt(process.env.DB_POOL_SIZE ?? '10', 10),
}));

export type DatabaseConfig = ReturnType<typeof databaseConfig>;

// src/config/jwt.config.ts
export const jwtConfig = registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET!,
  expiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
  refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '30d',
}));

// src/config/mail.config.ts
export const mailConfig = registerAs('mail', () => ({
  host: process.env.SMTP_HOST!,
  port: parseInt(process.env.SMTP_PORT ?? '587', 10),
  user: process.env.SMTP_USER!,
  pass: process.env.SMTP_PASS!,
  fromName: process.env.MAIL_FROM_NAME ?? 'App',
}));
// app.module.ts — Load các config namespace
ConfigModule.forRoot({
  isGlobal: true,
  load: [databaseConfig, jwtConfig, mailConfig],
}),

5. Inject Config vào Service

// Cách 1: ConfigService (generic)
@Injectable()
export class AppService {
  constructor(private configService: ConfigService) {}

  getPort(): number {
    return this.configService.get<number>('PORT', 3000)!;
  }

  getDatabaseUrl(): string {
    // Lấy config theo namespace
    return this.configService.get<string>('database.host')!;
  }
}

// Cách 2: @InjectConfig với namespace (type-safe hơn)
import { ConfigType } from '@nestjs/config';

@Injectable()
export class DatabaseService {
  constructor(
    @Inject(databaseConfig.KEY)
    private dbConfig: ConfigType<typeof databaseConfig>,
  ) {}

  getConnectionString(): string {
    // IDE autocomplete đầy đủ!
    return `postgresql://${this.dbConfig.username}:${this.dbConfig.password}@${this.dbConfig.host}:${this.dbConfig.port}/${this.dbConfig.name}`;
  }
}

6. TypeORM dùng Config

// src/database/database.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    type: 'postgres',
    host: config.get('database.host'),
    port: config.get<number>('database.port'),
    username: config.get('database.username'),
    password: config.get('database.password'),
    database: config.get('database.name'),
    ssl: config.get<boolean>('database.ssl'),
    entities: [__dirname + '/../**/*.entity{.ts,.js}'],
    synchronize: config.get('NODE_ENV') !== 'production',
  }),
  inject: [ConfigService],
}),

7. Validate với class-validator (không dùng Joi)

// src/config/env.validation.ts
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, IsString, Min, validateSync } from 'class-validator';

enum Environment {
  Development = 'development',
  Production = 'production',
  Test = 'test',
}

class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment = Environment.Development;

  @IsNumber()
  @Min(1)
  PORT: number = 3000;

  @IsString()
  DB_HOST: string;

  @IsNumber()
  DB_PORT: number = 5432;

  @IsString()
  JWT_SECRET: string;
}

export function validate(config: Record<string, unknown>) {
  const validatedConfig = plainToClass(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });

  const errors = validateSync(validatedConfig, { skipMissingProperties: false });

  if (errors.length > 0) {
    throw new Error(errors.toString());
  }

  return validatedConfig;
}

// Dùng trong ConfigModule
ConfigModule.forRoot({
  validate,
}),

8. Multiple .env files theo environment

.env                    ← Shared defaults
.env.development        ← Dev overrides
.env.production         ← Prod overrides
.env.test               ← Test overrides
.env.local              ← Local overrides (gitignore)
# package.json scripts
"start:dev": "NODE_ENV=development nest start --watch",
"start:prod": "NODE_ENV=production node dist/main",
"test": "NODE_ENV=test jest",

9. Kết luận

  • ConfigModule.forRoot({ isGlobal: true }): Một lần, dùng được mọi nơi
  • Joi validation: Validate ngay khi khởi động — fail fast, rõ ràng lỗi gì thiếu
  • Namespace registerAs: Nhóm config theo domain (database, jwt, mail) với TypeScript type-safe
  • @Inject(config.KEY): Inject có type-checking đầy đủ — IDE autocomplete
  • .env.{environment}: Phân tách config theo môi trường, không hard-code

Config validation là tuyến phòng thủ đầu tiên — đừng để thiếu JWT_SECRET gây lỗi sau 2 giờ runtime.