Dynamic Environment Variables in Angular: A Docker-Ready Solution

Learn how to inject environment variables into Angular apps at runtime using Docker - no more rebuilds for config changes! Complete guide with code examples.

Dynamic Environment Variables in Angular: A Docker-Ready Solution

Ever deployed an Angular app only to realize you hardcoded the API URL? Or found yourself rebuilding your entire application just to change a single configuration value? You're not alone! Angular's build-time environment system works great for development, but it falls short when you need true runtime configuration flexibility.

In this guide, we'll build a robust solution that lets you inject environment variables at container startup, making your Angular apps truly portable across different environments without rebuilding.

The Problem: Build-Time vs Runtime Configuration

Angular's default environment system bakes configuration values into your JavaScript bundles during the build process. This means:

  • ✅ Great for development and simple deployments
  • ❌ Requires rebuilding for different environments
  • ❌ Can't change configs after deployment
  • ❌ Makes container deployments inflexible

What we need is a way to inject configuration values when our container starts up, not when we build our app.

The Solution: Runtime Configuration with Docker

Our approach uses a template system that generates a JavaScript configuration file at container startup. Here's how it works:

  1. Template File: Contains placeholders for environment variables
  2. Startup Script: Replaces placeholders with actual values when container starts
  3. Angular Service: Reads the generated configuration
  4. Build Configuration: Includes our config file in the build output

Let's dive into the implementation!

Step 1: Create the Runtime Configuration File

First, create src/env-config.js - this will be our runtime configuration file:

// This file will be dynamically generated at runtime
// It provides runtime environment configuration for the Angular app
(function (window) {
  // Default values (used if environment variables are not provided)
  var defaultConfig = {
    apiUrl: null,
    favColor: null,
    favCatchPhrase: null,
    // Add other default settings as needed
  };

  // Expose the configuration to the application
  window.APP_CONFIG = defaultConfig;
})(window);

This file creates a global APP_CONFIG object that our Angular app can access. The beauty is it's just vanilla JavaScript, so it won't get minified or processed by Angular's build system.

Step 2: Set Up Angular Environment Files

Create your standard environment files, but with a twist - the production environment will check for runtime config.

src/environments/environment.ts (Development):

import { Environment } from "../app/_services/config.service";

export const environment: Environment = {
  production: false,
  cache: {
    logging: false,
  },
  apiUrl: 'http://localhost:5211',
  favColor: 'red',
  favCatchPhrase: 'I like developing',
};

src/environments/environment.prod.ts (Production):

import { Environment } from "../app/_services/config.service";

export const environment: Environment = {
  production: true,
  cache: {
    logging: false,
  },
  apiUrl: window.location.origin, // Fallback if runtime config fails
  favColor: 'yellow',
  favCatchPhrase: 'I like producing',
};

Step 3: Create the Configuration Service

This is where the magic happens. Our service intelligently chooses between compile-time and runtime configuration:

// src/app/services/config.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';

// Declare the global APP_CONFIG
declare global {
  interface Window {
    APP_CONFIG?: EnvironmentConfig;
  }
}

@Injectable({
  providedIn: 'root',
})
export class ConfigService {
  private config: EnvironmentConfig;

  constructor() {
    // Initialize config with environment values
    this.config = {
      apiUrl: environment.apiUrl,
      favColor: environment.favColor,
      favCatchPhrase: environment.favCatchPhrase,
    };

    // In production, override with window.APP_CONFIG values where they exist
    if (environment.production && window.APP_CONFIG) {
      // Check and override API URL
      if (
        window.APP_CONFIG.apiUrl !== undefined &&
        window.APP_CONFIG.apiUrl !== null
      ) {
        this.config.apiUrl = window.APP_CONFIG.apiUrl;
      }

      // Check and override other properties
      if (window.APP_CONFIG.favColor) {
        this.config.favColor = window.APP_CONFIG.favColor;
      }

      if (window.APP_CONFIG.favCatchPhrase) {
        this.config.favCatchPhrase = window.APP_CONFIG.favCatchPhrase;
      }
    }
  }

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

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

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

export interface EnvironmentConfig {
  apiUrl: string;
  favColor: string;
  favCatchPhrase: string;
}

export interface Environment extends EnvironmentConfig {
  production: boolean;
  cache: {
    logging: boolean;
  };
}

The service follows a simple priority system:

  1. Development: Use environment.ts values
  2. Production: Use environment.prod.ts as fallback, override with runtime config if available

Step 4: Configure Angular Build

Update your angular.json to include the config file and set up production file replacements:

{
  "projects": {
    "your-app-name": {
      "architect": {
        "build": {
          "options": {
            "assets": [
              {
                "glob": "env-config.js",
                "input": "src"
              },
              {
                "glob": "**/*",
                "input": "public"
              }
            ]
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ]
            }
          }
        }
      }
    }
  }
}

This ensures our env-config.js file gets copied to the build output and that production builds use the correct environment file.

Step 5: Create the Template and Scripts

Now for the Docker magic! Create these files in your project root:

env-config.template.js - Template with placeholders:

(function (window) {
  var appConfig = {
    production: true,
    apiUrl: '${API_URL}',
    favColor: '${FAV_COLOR}',
    favCatchPhrase: '${FAV_CATCH_PHRASE}',
    // Add other environment-controlled settings as needed
  };

  window.APP_CONFIG = appConfig;
})(window);

generate-env-config.sh - Startup script that does the replacement:

#!/bin/bash
# This script generates the runtime config js file by replacing environment variables

# File paths
TEMPLATE_FILE="/usr/local/bin/env-config.template.js"
OUTPUT_FILE="/usr/share/nginx/html/env-config.js"

echo "Generating environment configuration..."

# Check if template file exists
if [ ! -f "$TEMPLATE_FILE" ]; then
  echo "No template found. Please ensure env-config.template.js exists."
  exit 1
fi

echo "Using template at $TEMPLATE_FILE"

# Copy template to output
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"

# List all environment variables you want to replace
ENV_VARS="API_URL FAV_COLOR FAV_CATCH_PHRASE"

# Replace each environment variable in the output file
for env_var in $ENV_VARS; do
  # Get the value of the environment variable
  env_value=${!env_var}
  
  # Only replace if the environment variable is set
  if [ -n "$env_value" ]; then
    echo "Setting $env_var to $env_value"
    # Replace the placeholder with the actual value
    sed -i "s|\${$env_var}|$env_value|g" "$OUTPUT_FILE"
  else
    echo "$env_var not set, replacing with undefined"
    # Replace the placeholder and surrounding quotes with undefined
    sed -i "s|'[[:space:]]*\${$env_var}[[:space:]]*'|undefined|g" "$OUTPUT_FILE"
    sed -i "s|\"[[:space:]]*\${$env_var}[[:space:]]*\"|undefined|g" "$OUTPUT_FILE"
    # If the placeholder is not within quotes, just replace it with undefined
    sed -i "s|\${$env_var}|undefined|g" "$OUTPUT_FILE"
  fi
done

echo "Environment configuration generated at $OUTPUT_FILE"

# Start nginx
exec nginx -g "daemon off;"
💡
Quick note about the generate-env-config.sh. Take note of the OUTPUT_FILE and ENV_VARS this will work for this example and nginx, but you might want to change it for your setup.

Step 6: Create the Dockerfile

Dockerfile:

# Stage 1: Build the Angular app
FROM node:18-alpine AS build

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies (including devDependencies needed for build)
RUN npm ci

# Install Angular CLI globally
RUN npm install -g @angular/cli@19

# Copy source code
COPY . ./

# Build the Angular app for production
RUN ng build --configuration production

# Stage 2: Serve the app with nginx
FROM nginx:alpine

# Install bash for better script debugging
RUN apk add --no-cache bash

# Copy the built app from the previous stage
COPY --from=build /app/dist/your-app-name/browser /usr/share/nginx/html/

# Copy your entry point script and template
COPY generate-env-config.sh /usr/local/bin/generate-env-config.sh
COPY env-config.template.js /usr/local/bin/env-config.template.js

# Fix line endings and make executable
RUN dos2unix /usr/local/bin/generate-env-config.sh || sed -i 's/\r$//' /usr/local/bin/generate-env-config.sh && \
    chmod +x /usr/local/bin/generate-env-config.sh

# Create the env-config.js file with proper permissions
RUN touch /usr/share/nginx/html/env-config.js && chmod 666 /usr/share/nginx/html/env-config.js

# Expose port 80
EXPOSE 80

# Run the script to generate env config before starting nginx
ENTRYPOINT ["/bin/bash", "/usr/local/bin/generate-env-config.sh"]

Step 7: Using Your Configuration

Now you can use your configuration service throughout your Angular app:

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

@Component({
  selector: 'app-root',
  template: `
    <h1>My App</h1>
    <p>API URL: {{ apiUrl }}</p>
    <p>Favorite Color: {{ favColor }}</p>
    <p>Catch Phrase: {{ favCatchPhrase }}</p>
  `
})
export class AppComponent implements OnInit {
  apiUrl = '';
  favColor = '';
  favCatchPhrase = '';

  constructor(private configService: ConfigService) {}

  ngOnInit() {
    this.apiUrl = this.configService.apiUrl;
    this.favColor = this.configService.favColor;
    this.favCatchPhrase = this.configService.favCatchPhrase;
  }
}

Running Your Application

Development (uses environment.ts):

ng serve

Production Docker (uses runtime variables):

# Build the image
docker build -t my-angular-app .

# Run with environment variables
docker run -p 8080:80 \
  -e API_URL="https://api.production.com" \
  -e FAV_COLOR="blue" \
  -e FAV_CATCH_PHRASE="Hello Production!" \
  my-angular-app

The Benefits

This approach gives you:

  • True Runtime Configuration: Change configs without rebuilding
  • Portable Containers: Same image works across all environments
  • Flexible Deployment: Perfect for Kubernetes, Docker Swarm, or any container orchestration
  • Fallback Safety: Always has sensible defaults if environment variables aren't provided
  • Development Friendly: No changes needed to your dev workflow

Pro Tips

  1. Security: Never put sensitive data in your template defaults - use undefined and validate in your service
  2. Validation: Add validation in your ConfigService to ensure required values are present
  3. Logging: The startup script logs what it's doing - check container logs if configs aren't working
  4. Testing: You can override window.APP_CONFIG in your unit tests for consistent testing

Wrapping Up

Runtime environment variables in Angular might seem tricky, but this Docker-based approach gives you the flexibility of server-side configuration with the simplicity of client-side deployment. Your DevOps team will thank you, and you'll never have to rebuild just to change an API URL again!

The complete example code is available on GitHub, so you can see it all working together. Happy deploying! 🎉