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:
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 .
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
CI/CD Pipeline Bloat: Your pipeline must build multiple times, increasing deployment time and complexity.
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
- Single Build Artefact - Build once, deploy to any environment
- Runtime Flexibility - Change configuration without rebuilding
- Simplified CI/CD - Faster pipelines, single build step
- Container-friendly - Perfect for Docker/Kubernetes deployments
- 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