Managing Environment Variables in Angular: Build-Time and Runtime Configuration Best Practices

Modern Angular applications often need different configuration values depending on where they are deployed. API endpoints, feature flags, authentication settings, logging options, and third-party service identifiers may differ between development, testing, staging, and production environments.

Angular provides built-in support for environment-specific build configurations, but there is an important distinction developers need to understand: build-time configuration is not the same as runtime environment variables.

This article explains how to use environment variables in Angular components, how Angular environment files work, when to use runtime configuration, and which approach is best for applications deployed with Docker, Kubernetes, or CI/CD pipelines.

Understanding Environment Configuration in Angular

Unlike a traditional backend application, an Angular application executes primarily inside the user’s browser. This means it cannot directly access operating-system environment variables such as:

API_URL=https://api.example.com
CLIENT_ID=my-client

after the application has already been built and delivered to the browser.

Instead, configuration usually follows one of two models:

  1. Build-time configuration using Angular environment files.
  2. Runtime configuration loaded when the application starts.

Choosing between these approaches is important when designing an Angular application.


Using Angular Environment Files

Angular supports environment-specific configuration through its build system. Different configuration files can be substituted depending on the selected build configuration.

A typical project might contain:

src/
└── environments/
    ├── environment.ts
    ├── environment.development.ts
    └── environment.staging.ts

If the environment files do not exist, Angular CLI can generate and configure them:

ng generate environments

Angular officially provides this command specifically for creating and configuring environment files.

A production configuration could look like this:

export const environment = {
  production: true,
  apiUrl: 'https://api.example.com',
  enableLogging: false
};

The development version could contain:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080',
  enableLogging: true
};

Importing Environment Variables into an Angular Component

Once the environment configuration exists, it can be imported directly into a component:

import { Component } from '@angular/core';
import { environment } from '../environments/environment';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html'
})
export class DashboardComponent {
  readonly apiUrl = environment.apiUrl;
  readonly production = environment.production;
}

The component can then use these values normally:

loadData(): void {
  console.log(`Connecting to ${environment.apiUrl}`);
}

Angular recommends importing the base environment file rather than importing a specific environment such as environment.development.ts. The build configuration determines which implementation is used.

This keeps application code independent from the deployment target.


How Angular Selects the Correct Environment

Angular’s build configuration can use fileReplacements to substitute files for specific targets.

A simplified angular.json configuration might contain:

{
  "configurations": {
    "development": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.development.ts"
        }
      ]
    }
  }
}

A development build can then be created using:

ng build --configuration development

while another configuration could be selected with:

ng build --configuration staging

Angular CLI supports named configurations and even allows multiple configurations to be applied in sequence.

This architecture makes environment files particularly useful for values known when the application is built.


Build-Time Configuration vs Runtime Configuration

One of the most important Angular configuration concepts is understanding when environment values become available.

With:

import { environment } from '../environments/environment';

the configuration becomes part of the compiled JavaScript bundle.

Consider:

export const environment = {
  apiUrl: 'https://api.example.com'
};

After:

ng build

the API URL is effectively embedded into the generated application.

Changing an operating-system environment variable afterward will not automatically modify the Angular application.

This distinction becomes especially important with containerized deployments.


Angular Environment Files Are Not for Secrets

Developers should never place sensitive credentials in Angular environment files.

For example, this is unsafe:

export const environment = {
  databasePassword: 'secret-password',
  privateApiKey: 'private-key'
};

Angular’s documentation explicitly warns that files under src/environments/ are bundled into the client-side application and therefore visible to users. Secrets should instead remain on the server side or in an appropriate secrets-management system.

Values normally suitable for frontend configuration include:

API base URLs
public OAuth client IDs
feature flags
application version
analytics identifiers
logging configuration
public service endpoints

Values that should remain server-side include:

database passwords
private API keys
client secrets
private certificates
service-account credentials
signing keys

A useful rule is:

If the browser needs the value, assume the user can see it.


Runtime Configuration in Angular

Build-time environments work well when a separate application build is created for each environment.

However, modern deployment architectures often aim to:

Build once, deploy everywhere.

For example, the same Docker image might be promoted through:

Development
    ↓
Testing
    ↓
Staging
    ↓
Production

Rebuilding the Angular application for every environment defeats some of the benefits of immutable container images.

Runtime configuration provides an alternative.


Loading Runtime Configuration from JSON

A common solution is to create a configuration file such as:

assets/config.json

Example:

{
  "apiUrl": "https://api.example.com",
  "enableLogging": false,
  "featureXEnabled": true
}

Angular can load this configuration when the application starts.

A service might be defined as:

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface AppConfig {
  apiUrl: string;
  enableLogging: boolean;
  featureXEnabled: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class ConfigService {

  private readonly http = inject(HttpClient);

  private config!: AppConfig;

  load(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.http.get<AppConfig>('/assets/config.json')
        .subscribe({
          next: config => {
            this.config = config;
            resolve();
          },
          error: reject
        });
    });
  }

  get apiUrl(): string {
    return this.config.apiUrl;
  }

  get featureXEnabled(): boolean {
    return this.config.featureXEnabled;
  }
}

Components can then consume the configuration through dependency injection:

import { Component, inject } from '@angular/core';
import { ConfigService } from './config.service';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html'
})
export class DashboardComponent {

  private readonly config = inject(ConfigService);

  readonly apiUrl = this.config.apiUrl;
}

Angular’s dependency injection system supports supplying both services and configuration values to components, making DI a natural abstraction for centralized application configuration.


Using inject() Instead of Constructor Injection

Traditionally, Angular services were commonly injected through constructors:

constructor(private configService: ConfigService) {}

Modern Angular also supports:

private readonly configService = inject(ConfigService);

Angular’s current style guidance prefers inject() over constructor parameter injection because it can improve readability and type inference, particularly when a class has multiple dependencies.

For example:

export class ProductComponent {

  private readonly config = inject(ConfigService);
  private readonly http = inject(HttpClient);
  private readonly router = inject(Router);

}

There is one important restriction: inject() must execute inside an Angular injection context, such as during component or service construction or within a field initializer. Calling it later from an arbitrary method or lifecycle hook is not valid.


Using an InjectionToken for Application Configuration

For larger applications, another clean approach is to expose configuration through Angular’s dependency injection system.

First define an interface:

export interface AppConfig {
  apiUrl: string;
  production: boolean;
}

Then create an injection token:

import { InjectionToken } from '@angular/core';

export const APP_CONFIG =
  new InjectionToken<AppConfig>('APP_CONFIG');

The configuration can then be provided during application bootstrap:

providers: [
  {
    provide: APP_CONFIG,
    useValue: {
      apiUrl: 'https://api.example.com',
      production: true
    }
  }
]

A component can consume it with:

import { Component, inject } from '@angular/core';
import { APP_CONFIG } from './app-config';

@Component({
  selector: 'app-example',
  template: `...`
})
export class ExampleComponent {

  readonly config = inject(APP_CONFIG);

}

InjectionToken is particularly useful for configuration because Angular DI can inject arbitrary values and objects, not only class instances.

This also improves testability because tests can easily provide a different configuration.


Runtime Configuration with Docker

Consider an Angular application packaged as a Docker image.

Instead of building separate images:

frontend:development
frontend:staging
frontend:production

an organization may prefer:

frontend:1.5.0

and deploy exactly that image everywhere.

At container startup, an environment-specific configuration file can be generated.

For example:

#!/bin/sh

cat <<EOF > /usr/share/nginx/html/assets/config.json
{
  "apiUrl": "${API_URL}",
  "enableLogging": ${ENABLE_LOGGING}
}
EOF

exec nginx -g "daemon off;"

The container could then receive:

API_URL=https://api.production.example.com
ENABLE_LOGGING=false

while staging uses:

API_URL=https://api.staging.example.com
ENABLE_LOGGING=true

The Angular application remains identical. Only its runtime configuration changes.


Runtime Configuration with Kubernetes

The same architecture works particularly well with Kubernetes.

Configuration can originate from:

ConfigMap
        ↓
Environment variables
        ↓
Container startup script
        ↓
config.json
        ↓
Angular ConfigService
        ↓
Angular components

For example:

env:
  - name: API_URL
    valueFrom:
      configMapKeyRef:
        name: frontend-config
        key: api-url

The container startup process converts that environment value into configuration that the browser can retrieve.

This provides a useful separation between:

Application artifact

and:

Deployment configuration

and allows the same frontend image to move between environments without recompilation.


Combining Build-Time and Runtime Configuration

Many production Angular applications benefit from combining both techniques.

A practical architecture is:

Angular environment.ts
        │
        ├── Build-related constants
        ├── compile-time feature selection
        └── application defaults

Runtime config.json
        │
        ├── API endpoints
        ├── environment-specific URLs
        ├── public integration IDs
        └── deployment-specific settings

For example:

// environment.ts

export const environment = {
  production: true,
  applicationName: 'My Application',
  version: '2.1.0'
};

while:

{
  "apiUrl": "https://api.example.com",
  "authenticationUrl": "https://auth.example.com"
}

contains deployment-specific values.

This gives developers the simplicity of Angular environments while retaining the flexibility required by modern deployment pipelines.


Common Mistakes to Avoid

Importing a Specific Environment File

Avoid:

import { environment }
  from '../environments/environment.production';

Prefer:

import { environment }
  from '../environments/environment';

Angular’s build configuration should determine the appropriate replacement.

Storing Secrets in Angular

Never rely on frontend code to hide:

passwords
private keys
API secrets
database credentials

Anything delivered to the browser must be considered accessible to the user.

Expecting process.env to Work Like Node.js

Angular browser applications are not Node.js backend processes.

Code such as:

const apiUrl = process.env.API_URL;

does not automatically provide access to environment variables from the server hosting the built Angular application.

The value must either be incorporated during the build process or exposed through a runtime configuration mechanism.

Scattering Configuration Throughout Components

Avoid hard-coded URLs such as:

this.http.get(
  'https://production.example.com/api/products'
);

Instead, centralize configuration:

this.http.get(
  `${this.config.apiUrl}/products`
);

This reduces duplication and makes testing and deployment substantially easier.


Recommended Architecture

For a conventional Angular application with separate builds for each environment:

environment.ts
environment.development.ts
environment.staging.ts

is usually sufficient.

For Angular applications deployed through modern CI/CD infrastructure:

                    Angular Application
                           │
                     ConfigService
                           │
                    Runtime Config
                           │
             ┌─────────────┴─────────────┐
             │                           │
          Docker                     Kubernetes
             │                           │
        ENV variables                  ConfigMap

is generally more flexible.

A mature application can therefore follow this principle:

Build-time environment files define application defaults; runtime configuration defines deployment-specific values; sensitive information remains on the backend.


Conclusion

Environment configuration may initially appear to be a minor Angular implementation detail, but it becomes an important architectural concern as an application moves from local development into automated CI/CD and containerized environments.

Angular’s environment files provide an effective mechanism for compile-time configuration, with Angular CLI supporting named configurations and file replacement.

For deployments where the same artifact should run in multiple environments, runtime configuration through JSON, dependency injection, or another external configuration source provides greater flexibility.

The most important principles are:

  • Keep environment-dependent values centralized.
  • Import the generic environment configuration rather than environment-specific files.
  • Understand the difference between build-time and runtime variables.
  • Never store secrets in frontend environment files.
  • Use Angular dependency injection to make configuration reusable and testable.
  • Consider runtime configuration when deploying the same Angular artifact through Docker or Kubernetes.

Following these practices creates Angular applications that are easier to maintain, test, deploy, and scale across development, staging, and production environments.

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back