Dynamic Runtime Configuration in Angular 19

󰃭 2025-06-06

Angular applications traditionally require separate builds for different environments (development, staging, production). This approach has limitations: longer CI/CD pipelines, multiple artefacts to manage, and the inability to change configuration without rebuilding. Let’s implement a better solution using runtime configuration.

The Problem with Traditional Environment Files

Angular’s default environment files require compile-time configuration:

// environment.prod.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.prod.example.com',
  authClientId: 'prod-client-id'
};

This means you need a separate build for each environment, which violates the “build once, deploy anywhere” principle.

Why This Matters

For static site hosting, this might seem manageable - you simply build and deploy different versions to different CDNs or storage buckets. However, even here you’re maintaining multiple build artefacts and running multiple build processes.

For containerised deployments, this approach becomes problematic:

  1. Multiple Docker Images: You need a separate Docker image for each environment, or complex build arguments:

    # Anti-pattern: Building environment-specific images
    docker build --build-arg ENV=staging -t myapp:staging .
    docker build --build-arg ENV=production -t myapp:production .
    
  2. Kubernetes Complexity: You can’t promote the same image through environments. Instead, you’re managing different images:

    # staging/deployment.yaml
    image: myapp:staging
    
    # production/deployment.yaml  
    image: myapp:production
    
  3. CI/CD Pipeline Bloat: Your pipeline must build multiple times, increasing deployment time and complexity.

  4. Configuration Changes Require Rebuilds: Need to update an API endpoint? That’s a full rebuild, push to registry, and pod restart.

The “build once, deploy anywhere” principle means creating a single immutable artefact that can be configured at runtime - essential for modern container orchestration where the same image should run in dev*, staging, and production with only configuration changes.

_* Sometimes it makes sense to run debug builds in dev and that is OK, but builds that go to staging should always be tested against and promoted to higher environments. _

Step 1: Create the Configuration Model

// src/app/core/config/runtime-config.model.ts
export interface RuntimeConfig {
  production: boolean;
  apiUrl: string;
  auth: {
    clientId: string;
    authority: string;
    redirectUri: string;
    apiScopes: string[];
  };
  [key: string]: any; // Allow extension
}

Step 2: Create the Configuration Service

// src/app/core/config/config.service.ts
import { Injectable } from '@angular/core';
import { RuntimeConfig } from './runtime-config.model';

@Injectable({ providedIn: 'root' })
export class ConfigService {
  private config: RuntimeConfig | null = null;
  
  async load(): Promise<RuntimeConfig> {
    const response = await fetch('/assets/config.json');
    if (!response.ok) {
      throw new Error(`Failed to load config: ${response.statusText}`);
    }
    this.config = await response.json();
    return this.config;
  }
  
  get<T = any>(key: string): T | undefined {
    if (!this.config) {
      throw new Error('Configuration not loaded');
    }
  
    // Support dot notation for nested properties
    const keys = key.split('.');
    let value: any = this.config;
  
    for (const k of keys) {
      if (value && typeof value === 'object' && k in value) {
        value = value[k];
      } else {
        return undefined;
      }
    }
  
    return value as T;
  }
  
  getAll(): RuntimeConfig {
    if (!this.config) {
      throw new Error('Configuration not loaded');
    }
    return this.config;
  }
}

Step 3: Update main.ts for Dynamic Bootstrap

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { ConfigService } from './app/core/config/config.service';
import { createDynamicAppConfig } from './app/app.config';

async function bootstrap() {
  try {
    // Load runtime configuration
    const configService = new ConfigService();
    const config = await configService.load();

    // Create dynamic app config
    const appConfig = createDynamicAppConfig(config);

    // Bootstrap with runtime configuration
    await bootstrapApplication(AppComponent, {
      providers: [
        ...appConfig.providers,
        { provide: ConfigService, useValue: configService }
      ]
    });
  } catch (error) {
    console.error('Failed to bootstrap:', error);
  }
}

bootstrap();

Step 4: Create Dynamic App Configuration

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { RuntimeConfig } from './core/config/runtime-config.model';
import { provideMSAL } from './msal.config';

export function createDynamicAppConfig(config: RuntimeConfig): ApplicationConfig {
  return {
    providers: [
      // Your providers here, using config values
      provideMSAL(config), // Example: MSAL with runtime config
      // ... other providers
    ]
  };
}

Step 5: MSAL Integration Example

For authentication libraries like MSAL, update factories to accept runtime configuration:

// src/app/msal.config.ts
import { IPublicClientApplication, PublicClientApplication } from '@azure/msal-browser';
import { RuntimeConfig } from './core/config/runtime-config.model';

export function MSALInstanceFactory(config: RuntimeConfig): IPublicClientApplication {
  return new PublicClientApplication({
    auth: {
      clientId: config.auth.clientId,
      authority: config.auth.authority,
      redirectUri: config.auth.redirectUri,
    },
    cache: {
      cacheLocation: 'localStorage'
    }
  });
}

export function provideMSAL(config: RuntimeConfig) {
  return [
    { provide: MSAL_INSTANCE, useFactory: () => MSALInstanceFactory(config) },
    // ... other MSAL providers
  ];
}

Step 6: Create Environment Configuration Files

// public/assets/config.json (local development)
{
  "production": false,
  "apiUrl": "http://localhost:3000/api",
  "auth": {
    "clientId": "dev-client-id",
    "authority": "https://login.microsoftonline.com/tenant",
    "redirectUri": "http://localhost:4200",
    "apiScopes": ["api://dev-api/.default"]
  }
}

Create similar files for each environment:

  • config.development.json
  • config.staging.json
  • config.production.json

Step 7: Simplify Environment Files

Since configuration is now loaded at runtime, environment files become minimal:

// src/environments/environment.ts
export const environment = {
  production: false
};

Step 8: Update Services to Use Runtime Config

// src/app/services/api.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ConfigService } from '../core/config/config.service';

@Injectable({ providedIn: 'root' })
export class ApiService {
  private readonly http = inject(HttpClient);
  private readonly config = inject(ConfigService);

  private get apiUrl(): string {
    return this.config.get<string>('apiUrl') ?? '';
  }

  getData() {
    return this.http.get(`${this.apiUrl}/data`);
  }
}

Deployment Strategy

During deployment, copy the appropriate config file:

# Deploy to staging
cp public/assets/config.staging.json dist/app/assets/config.json

# Deploy to production
cp public/assets/config.production.json dist/app/assets/config.json

Or use environment variables in your CI/CD pipeline:

# Example GitHub Actions
- name: Configure for ${{ env.ENVIRONMENT }}
  run: |
    cp public/assets/config.${{ env.ENVIRONMENT }}.json dist/app/assets/config.json

Benefits

  1. Single Build Artefact - Build once, deploy to any environment
  2. Runtime Flexibility - Change configuration without rebuilding
  3. Simplified CI/CD - Faster pipelines, single build step
  4. Container-friendly - Perfect for Docker/Kubernetes deployments
  5. A/B Testing - Easy configuration experiments

Considerations

  • Security: Store sensitive values (API keys, secrets) in secure services like Azure Key Vault or AWS Secrets Manager, not in config.json
  • Caching: Configure proper cache headers for config.json
  • Fallbacks: Handle configuration loading failures gracefully
  • TypeScript: Maintain type safety with proper interfaces
  • Performance: Config is loaded once at startup - minimal impact

Conclusion

Runtime configuration in Angular 19 provides a cleaner, more flexible approach to environment management. By loading configuration at startup, you achieve true “build once, deploy anywhere” capability whilst maintaining type safety and developer experience.

This pattern is particularly valuable for containerised deployments, where you can inject configuration at runtime through volume mounts or configuration management systems and could be further enhanced with dynamic configuration updates via APIs or event-driven systems.

Future Enhancements

  • Dynamic Updates: Implement dynamic configuration updates via APIs or event-driven systems
  • Multi-tenancy: Support multi-tenant configurations for SaaS applications
  • Monitoring: Monitor configuration changes and their impact on application performance