Configuration Management

June 3, 2026 ยท View on GitHub

Configuration Management

Managing application configuration is often more complex than it appears.

Modern applications usually have many configuration parameters spread across multiple files and environments. The same application may run in different environments such as:

  • local
  • test
  • development
  • production

A common problem at the start of many projects is keeping configuration documentation up to date. Developers often struggle to understand how to correctly configure the application locally.

The approach used in this project solves this problem by combining versioned configuration, local overrides, and secure secret management.

Environment Configuration Strategy

The configuration system is built around two main files - .env.common, .env

.env.common - this is the primary configuration file used for local development.

Characteristics:

  • contains all required environment variables
  • fully configured for running the application locally
  • does not contain sensitive secrets
  • committed to the repository

Because this file is versioned, it acts as living documentation for all configuration variables required by the application.

.env - this file is used for local overrides.

Sometimes developers need to change specific configuration values locally. For example:

  • running a database on a different port
  • using a local Redis instance
  • modifying debug options

Instead of modifying .env.common, developers can override values in .env.

Example scenario:

A developer needs to override the database port locally.

DB_PORT=5332

Key properties:

  • overrides variables defined in .env.common
  • not committed to the repository
  • optional for developers
  • prevents merge conflicts between team members

Each developer may have their own .env file.

Test Environment Overrides

For test environments, some parts of the system configuration may also need to be overridden. For example, throttle limits may need to be increased because automated tests that repeatedly call the API could otherwise be blocked or disable Sentry for test env.

Example:

THROTTLE_TTL=20000000
THROTTLE_LIMIT=10000
SENTRY_ENABLED=false

For such cases, we can use a dedicated .env.test file that overrides values from .env.common specifically for test execution.

Secret Management

Sensitive secrets such as:

  • database passwords
  • API keys
  • JWT secrets
  • third-party credentials

are never stored in environment files inside the repository.

Instead, secrets are injected directly into the container at runtime using Terraform.

This approach provides several important benefits:

  • secrets are not visible in the repository
  • secrets are not exposed during CI/CD
  • secrets are stored securely in infrastructure systems (e.g. AWS Secrets Manager)
  • only infrastructure-level access can retrieve them

In practice, secrets are passed to the container during deployment via Terraform configuration.

This significantly reduces the attack surface compared to storing secrets in environment files.

NestJS Configuration Setup

In this project, NestJS loads configuration from both .env and .env.common.

.env takes precedence over .env.common.

Example configuration:

ConfigModule.forRoot({
  envFilePath: [join(process.cwd(), '.env'), join(process.cwd(), '.env.common')],
});

The same logic is applied in the database manager:

config({
  override: false,
  path: [
    join(process.cwd(), '.env'),
    join(process.cwd(), '.env.common'),
  ],
});

This ensures that:

  1. .env.common provides default values
  2. .env can override them locally if needed

Structured Configuration Modules

At the application level, configuration is divided into semantic modules.

Instead of loading configuration as a large unstructured object, we split it into logical domains such as:

  • app
  • database
  • redis

This makes configuration easier to understand, test, and maintain.

Example configuration module:

class AppConfig {
  @IsString()
  APP_DESCRIPTION = packageJson.description || 'Description';

  @IsString()
  APP_HOST = '0.0.0.0';

  @IsString()
  APP_NAME = packageJson.name || 'App';

  @IsNumber()
  @Transform(({ value }) => Number(value))
  APP_PORT = 3000;

  @IsString()
  APP_VERSION = packageJson.version || '1.0.0';
}

export const appConfig = registerAs<AppConfig, ConfigFactory<AppConfigInterface>>(
  'appConfig',
  (): AppConfigInterface => {
    const config = validateConfig(AppConfig);

    return {
      appDescription: config.APP_DESCRIPTION,
      appHost: config.APP_HOST,
      appName: config.APP_NAME,
      appPort: config.APP_PORT,
      appVersion: config.APP_VERSION,
    };
  },
);

This approach provides several benefits:

  • configuration validation
  • typed configuration
  • clear separation of concerns
  • predictable configuration access

Accessing Configuration

Configuration values are accessed through NestJS ConfigService.

Example:

const configApp = configService.getOrThrow<ConfigType<typeof appConfig>>('appConfig');

await app.listen(configApp.appPort, configApp.appHost);

Using getOrThrow ensures that the application fails fast if a required configuration value is missing.

Why This Approach Works Well

This configuration strategy provides several advantages:

  • configuration becomes self-documented
  • developers can run the project immediately after cloning
  • secrets remain secure and outside the repository
  • configuration is type-safe and validated
  • configuration is organized into small, predictable modules

As the project grows, this structure allows the configuration system to scale without becoming difficult to maintain.