On this page

Configuration and environment management

12 min read TextCh. 5 — Production

Why configuration management matters

Hard-coding configuration values (database URLs, API keys, ports) is one of the most common mistakes in backend development. The problems become obvious when you try to deploy to multiple environments (development, staging, production):

  • Database host is localhost in development but db.prod.example.com in production
  • JWT secret must be different across environments
  • Log level should be debug in development and error in production
  • CORS origins differ between environments

NestJS provides @nestjs/config — a wrapper around the popular dotenv library — with type-safe configuration namespaces and built-in validation.

Installing @nestjs/config

npm install @nestjs/config joi

joi is used for schema validation of environment variables.

Environment files

Create separate .env files for each environment:

.env                    # Defaults (committed to git — no secrets)
.env.development        # Development overrides (committed)
.env.production         # Production template (committed, no real values)
.env.test               # Test environment (committed)
.env.local              # Local overrides (gitignored)

Example .env.development:

NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=bookstore_dev
JWT_SECRET=dev-secret-at-least-32-characters-long
JWT_REFRESH_SECRET=dev-refresh-secret-at-least-32-chars

Always add .env.local and any file with real secrets to .gitignore.

Configuration namespaces

The registerAs function creates a configuration namespace — a typed slice of configuration:

import { registerAs } from '@nestjs/config';

export const mailConfig = registerAs('mail', () => ({
  host: process.env['MAIL_HOST'] ?? 'smtp.mailtrap.io',
  port: parseInt(process.env['MAIL_PORT'] ?? '587', 10),
  user: process.env['MAIL_USER'],
  pass: process.env['MAIL_PASS'],
  from: process.env['MAIL_FROM'] ?? '[email protected]',
}));

Injecting ConfigService

Inject ConfigService to read configuration values anywhere in the application:

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

@Injectable()
export class BooksService {
  private readonly maxBooksPerUser: number;

  constructor(private readonly configService: ConfigService) {
    this.maxBooksPerUser = this.configService.get<number>('app.maxBooksPerUser', 10);
  }
}

The second argument to get() is the default value if the key is not found.

Injecting namespaced config

For namespaced configs, inject with @InjectConfig():

import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { jwtConfig } from '../config/app.config';

@Injectable()
export class AuthService {
  constructor(
    @Inject(jwtConfig.KEY)
    private readonly jwt: ConfigType<typeof jwtConfig>,
  ) {}

  getSecret(): string {
    return this.jwt.secret ?? ''; // Fully typed
  }
}

This approach gives you full TypeScript type inference for your configuration — no string keys and no any types.

Dynamic TypeORM configuration

Use TypeOrmModule.forRootAsync() to build the configuration from ConfigService:

TypeOrmModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'postgres',
    host: config.getOrThrow<string>('database.host'),
    port: config.getOrThrow<number>('database.port'),
    username: config.getOrThrow<string>('database.username'),
    password: config.getOrThrow<string>('database.password'),
    database: config.getOrThrow<string>('database.name'),
    autoLoadEntities: true,
    synchronize: config.get('app.env') === 'development',
    logging: config.get('app.env') === 'development',
  }),
})

getOrThrow() throws an error if the key is missing — useful for critical configuration that must exist.

Main.ts configuration

Read configuration in main.ts before bootstrapping:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);

  const port = configService.get<number>('app.port', 3000);
  const prefix = configService.get<string>('app.apiPrefix', 'api');
  const corsOrigin = configService.get<string>('app.corsOrigin', '*');

  app.setGlobalPrefix(prefix);
  app.enableCors({ origin: corsOrigin, credentials: true });

  await app.listen(port);
  console.log(`Server running on http://localhost:${port}/${prefix}`);
}

Feature flags

Use environment variables for feature flags:

export const featuresConfig = registerAs('features', () => ({
  enableWebSockets: process.env['FEATURE_WEBSOCKETS'] === 'true',
  enableSwagger: process.env['FEATURE_SWAGGER'] !== 'false',
  enableRateLimiting: process.env['FEATURE_RATE_LIMITING'] !== 'false',
  maxUploadSizeMb: parseInt(process.env['MAX_UPLOAD_SIZE_MB'] ?? '10', 10),
}));

Custom config loading

For complex configuration needs, implement ConfigFactory:

export default async (): Promise<Record<string, unknown>> => {
  // Load config from a remote source, database, or secrets manager
  const secretsManagerConfig = await fetchFromSecretsManager();

  return {
    database: {
      host: secretsManagerConfig.dbHost,
      password: secretsManagerConfig.dbPassword,
    },
  };
};

Configuration in tests

For tests, use ConfigModule.forRoot({ load: [testConfig] }) or override specific values with process.env:

beforeAll(async () => {
  process.env['DB_NAME'] = 'bookstore_test';
  process.env['JWT_SECRET'] = 'test-secret-at-least-32-characters';

  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();
});
isGlobal: true for ConfigModule
Set isGlobal: true when registering ConfigModule so that ConfigService is available for injection everywhere without needing to import ConfigModule in every feature module. This is the recommended setup for almost all applications.
Validate all environment variables at startup
Use the validationSchema option with Joi to validate environment variables when the application starts. Without validation, a missing JWT_SECRET might cause authentication to silently fail in production rather than crashing immediately with a clear error message.
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';

export const appConfig = registerAs('app', () => ({
  port: parseInt(process.env['PORT'] ?? '3000', 10),
  env: process.env['NODE_ENV'] ?? 'development',
  apiPrefix: process.env['API_PREFIX'] ?? 'api',
  corsOrigin: process.env['CORS_ORIGIN'] ?? 'http://localhost:4200',
}));

export const databaseConfig = registerAs('database', () => ({
  host: process.env['DB_HOST'] ?? 'localhost',
  port: parseInt(process.env['DB_PORT'] ?? '5432', 10),
  username: process.env['DB_USER'] ?? 'postgres',
  password: process.env['DB_PASS'] ?? '',
  name: process.env['DB_NAME'] ?? 'bookstore',
}));

export const jwtConfig = registerAs('jwt', () => ({
  secret: process.env['JWT_SECRET'],
  expiresIn: process.env['JWT_EXPIRES_IN'] ?? '15m',
  refreshSecret: process.env['JWT_REFRESH_SECRET'],
  refreshExpiresIn: process.env['JWT_REFRESH_EXPIRES_IN'] ?? '7d',
}));

// Validation schema for all env variables
export const envValidationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test')
    .default('development'),
  PORT: Joi.number().default(3000),
  DB_HOST: Joi.string().required(),
  DB_PORT: Joi.number().default(5432),
  DB_USER: Joi.string().required(),
  DB_PASS: Joi.string().required(),
  DB_NAME: Joi.string().required(),
  JWT_SECRET: Joi.string().min(32).required(),
  JWT_REFRESH_SECRET: Joi.string().min(32).required(),
});