DEV Community

Bo Vandersteene
Bo Vandersteene

Posted on

Runtime Environment Variables in Frontend Applications

Runtime Environment Variables in Frontend Applications

When deploying frontend applications with Docker, environment variables require special handling. This is because bundlers like Vite, Webpack, and esbuild resolve environment variables at build time, not at runtime.

Since environment variables typically differ per environment (DEV, TST, QAS, PRD), we need a runtime solution that allows us to build once, deploy everywhere.

This guide shows how to inject environment variables at container startup using a config.json file that your application fetches at runtime.


How It Works

  1. Build phase: Your app is built without environment-specific values
  2. Container startup: A shell script generates config.json from environment variables
  3. App initialization: Your frontend fetches config.json before rendering
┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│   Docker Build  │      │ Container Start │      │   App Loads     │
│                 │      │                 │      │                 │
│  npm run build  │ ──▶  │ entrypoint.sh   │ ──▶  │ fetch config.json│
│  (no env vars)  │      │ creates config  │      │ then render     │
└─────────────────┘      └─────────────────┘      └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Docker Configuration

docker-entrypoint.sh

This is the heart of the runtime configuration system. The entrypoint script runs each time the container starts, generating a fresh config.json file from the current environment variables.

Key benefits:

  • No rebuild required when configuration changes
  • Just restart the container to pick up new values
  • Works with any orchestration system (Kubernetes, Docker Compose, etc.)
#!/bin/sh
set -e

# Generate runtime config from environment variables
# This file will be served by your web server and fetched by the app at startup
#
# The heredoc (<<EOF) creates the JSON file inline
# Shell variables like ${API_URL} are expanded when the script runs
cat > /srv/config.json <<EOF
{
  "API_URL": "${API_URL}",
  "AUTH_URL": "${AUTH_URL}",
  "FEATURE_FLAGS": "${FEATURE_FLAGS}",
  "ENV": "${ENV}"
}
EOF
# NOTE: Add any additional environment variables you need above
# Make sure to also update your config interface/type in the frontend code
#
# TIP: For optional variables with defaults, you can use:
#   "TIMEOUT": "${TIMEOUT:-5000}"
# This sets TIMEOUT to 5000 if the environment variable is not set

echo "Generated /srv/config.json with runtime configuration"

# Start Caddy as the main process
# Using 'exec' replaces the shell process with Caddy, which:
#   - Ensures proper signal handling (SIGTERM, SIGINT)
#   - Makes Caddy PID 1 in the container
#   - Allows graceful shutdown to work correctly
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
Enter fullscreen mode Exit fullscreen mode

Dockerfile

This Dockerfile uses a multi-stage build pattern to create a small, production-ready image. The first stage builds the application, and the second stage contains only the built assets and the web server.

Why multi-stage builds?

  • Keeps the final image small (no node_modules, no build tools)
  • Separates build-time dependencies from runtime
  • Improves security by reducing attack surface
# =============================================================================
# Stage 1: Build the frontend application
# =============================================================================
FROM node:23-alpine AS builder
WORKDIR /app

# Enable pnpm via corepack (built into Node.js)
# Replace with npm/yarn if preferred:
#   npm: Remove these lines entirely
#   yarn: RUN corepack enable && corepack prepare yarn@stable --activate
RUN corepack enable && corepack prepare pnpm@latest --activate

# Copy package files first for better layer caching
# Docker caches each layer, so if package.json hasn't changed,
# the dependency installation step is skipped on subsequent builds
COPY package.json pnpm-lock.yaml* ./

# Install all dependencies (including devDependencies needed for build)
# Use --frozen-lockfile in CI to ensure reproducible builds:
#   RUN pnpm install --frozen-lockfile
RUN pnpm install

# Copy source code and build the production bundle
# This step is only re-run when source files change
COPY . .
RUN pnpm run build

# =============================================================================
# Stage 2: Production image with Caddy
# =============================================================================
FROM caddy:alpine

# Copy built assets from builder stage
# Adjust /app/dist to match your framework's output directory:
#   - Vue/Vite: /app/dist
#   - React (CRA): /app/build
#   - Angular: /app/dist/<project-name>
#   - Next.js (static): /app/out
COPY --from=builder /app/dist /srv

# Copy Caddy configuration
COPY Caddyfile /etc/caddy/Caddyfile

# Copy and prepare the entrypoint script
# This script generates config.json from environment variables at runtime
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

# Default port (can be overridden via environment variable)
# This is just documentation and a default; the actual port is set in Caddyfile
ENV PORT=9000

# Document which port the container listens on
EXPOSE ${PORT}

# Use the entrypoint script to generate config before starting Caddy
# ENTRYPOINT runs before CMD, making it perfect for initialization tasks
ENTRYPOINT ["/docker-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Caddyfile

Caddy is a modern web server with automatic HTTPS and simple configuration. This Caddyfile configures it to serve a Single Page Application (SPA) with client-side routing support.

Why Caddy?

  • Zero-config HTTPS with automatic certificate management
  • Simple, readable configuration syntax
  • Small footprint, perfect for containers
  • Built-in compression and modern HTTP features
# Listen on the configured port
# The {$PORT} syntax reads from environment variables
:{$PORT}

# Serve static files from /srv
# This is where our built frontend assets live
root * /srv

# Enable gzip compression for text-based files
# Significantly reduces transfer size for JS, CSS, HTML, and JSON
encode gzip

# Serve static files with appropriate MIME types
file_server

# SPA fallback: serve index.html for routes not matching a file
# This is essential for client-side routing (Vue Router, React Router, etc.)
#
# Without this, refreshing on /dashboard would return 404
# because there's no physical /dashboard file
#
# How it works:
#   1. Try to serve the exact path requested
#   2. If no file exists, serve index.html instead
#   3. The frontend router then handles the route
try_files {path} /index.html
Enter fullscreen mode Exit fullscreen mode

Frontend Implementation

Configuration Interface

Before implementing the config loader, define a TypeScript interface that describes your configuration shape. This provides type safety and autocompletion throughout your application.

Best practices:

  • Keep this interface in sync with your docker-entrypoint.sh
  • Use union types for known values (like environment names)
  • Add JSDoc comments for complex configuration options
/**
 * Runtime configuration interface
 * Update this interface when adding new environment variables
 *
 * This interface should match the JSON structure generated
 * by docker-entrypoint.sh
 */
export interface RuntimeConfig {
  /** Base URL for backend API requests (e.g., "https://api.example.com") */
  API_URL: string;

  /** Authentication service URL for OAuth/OIDC flows */
  AUTH_URL: string;

  /** Comma-separated list of enabled feature flags */
  FEATURE_FLAGS: string;

  /** Current deployment environment */
  ENV: "DEV" | "TST" | "QAS" | "PRD";
}
Enter fullscreen mode Exit fullscreen mode

runtime-config.ts

This is the core configuration loader used by all frameworks. It fetches the config.json file generated by the Docker entrypoint and caches it for synchronous access throughout your application.

Important: Call loadRuntimeConfig() during application startup, before any code that needs configuration values. This ensures the config is loaded and cached before getRuntimeConfig() is called.

Why this pattern?

  • Single fetch at startup, then synchronous access everywhere
  • No need to pass config through props or context (though you can)
  • Works with any framework or vanilla JavaScript
  • Fails fast if config is missing
export interface RuntimeConfig {
  API_URL: string;
  AUTH_URL: string;
  FEATURE_FLAGS: string;
  ENV: "DEV" | "TST" | "QAS" | "PRD";
}

// Cache the config after first load to avoid repeated fetches
// Using module-level variable ensures single source of truth
let cachedConfig: RuntimeConfig | null = null;

/**
 * Loads the runtime configuration from config.json
 * This file is generated by docker-entrypoint.sh at container startup
 *
 * @returns Promise resolving to the runtime configuration
 * @throws Error if config cannot be loaded (network error, invalid JSON, etc.)
 *
 * @example
 * // Call once at app startup
 * await loadRuntimeConfig();
 *
 * // Then use getRuntimeConfig() anywhere
 * const config = getRuntimeConfig();
 */
export async function loadRuntimeConfig(): Promise<RuntimeConfig> {
  // Return cached config if already loaded
  // This makes subsequent calls instant and prevents race conditions
  if (cachedConfig) {
    return cachedConfig;
  }

  try {
    // Use BASE_URL to support apps deployed at subpaths (e.g., /my-app/)
    // Vite sets this based on the `base` option in vite.config.ts
    // Falls back to "/" for root deployments
    const baseUrl = import.meta.env.BASE_URL || "/";
    const response = await fetch(`${baseUrl}config.json`);

    // Check for HTTP errors (404, 500, etc.)
    if (!response.ok) {
      throw new Error(`Failed to load config: ${response.status}`);
    }

    // Parse and cache the configuration
    // The non-null assertion (!) is safe because we just set it
    cachedConfig = await response.json();
    return cachedConfig!;
  } catch (error) {
    // Log the error for debugging, then re-throw
    // This ensures the app doesn't silently fail with missing config
    console.error("Failed to load runtime config:", error);
    throw new Error("Failed to load runtime config");
  }
}

/**
 * Synchronously retrieves the cached runtime configuration
 * Must call loadRuntimeConfig() before using this function
 *
 * @returns The cached runtime configuration
 * @throws Error if config has not been loaded yet
 *
 * @example
 * // In any component or service (after loadRuntimeConfig has been called)
 * const config = getRuntimeConfig();
 * console.log(config.API_URL);
 */
export function getRuntimeConfig(): RuntimeConfig {
  // Fail fast with a clear error message if config wasn't loaded
  // This catches programming errors where getRuntimeConfig is called too early
  if (!cachedConfig) {
    throw new Error(
      "Runtime config not loaded. Call loadRuntimeConfig() first."
    );
  }
  return cachedConfig;
}
Enter fullscreen mode Exit fullscreen mode

Native JavaScript

For vanilla JavaScript applications or simple static sites without a framework. This approach works with any bundler (Vite, Webpack, Rollup) or even without one.

When to use:

  • Simple static sites
  • Legacy applications
  • Micro-frontends
  • When you don't need a framework

main.js

import { loadRuntimeConfig, getRuntimeConfig } from "./runtime-config.js";

// Load config before initializing the application
// The promise chain ensures config is ready before any app code runs
loadRuntimeConfig()
  .then(() => {
    // Config is now loaded and cached
    const config = getRuntimeConfig();
    console.log(`Running in ${config.ENV} environment`);
    console.log(`API endpoint: ${config.API_URL}`);

    // Initialize your application here
    // At this point, getRuntimeConfig() can be called from anywhere
    initApp();
  })
  .catch((error) => {
    // Handle config loading failure gracefully
    // This might happen if config.json is missing or malformed
    console.error("Failed to initialize app:", error);

    // Show a user-friendly error message
    // In production, you might want a more polished error page
    document.body.innerHTML = `
      <div style="padding: 20px; text-align: center;">
        <h1>Configuration Error</h1>
        <p>Failed to load application configuration. Please try refreshing the page.</p>
      </div>
    `;
  });

/**
 * Initialize your application
 * Called after config is successfully loaded
 */
function initApp() {
  const config = getRuntimeConfig();

  // Example: Set up API client with base URL
  // window.apiClient = new ApiClient(config.API_URL);

  // Example: Initialize feature flags
  // window.features = parseFeatureFlags(config.FEATURE_FLAGS);

  // Your application initialization code here
}
Enter fullscreen mode Exit fullscreen mode

Vue

Vue applications load the configuration before mounting the app to the DOM. This ensures all components have access to configuration from the moment they're created.

Key points:

  • Load config in main.ts before app.mount()
  • Access config in components via getRuntimeConfig()
  • No need for Vuex/Pinia for simple config access

main.ts

import { createApp } from "vue";
import App from "./App.vue";
import { loadRuntimeConfig } from "./config/runtime-config";

// Create the Vue app instance
// We create it early but don't mount until config is loaded
const app = createApp(App);

// Load runtime config before mounting the app
// This ensures config is available to all components from the start
loadRuntimeConfig()
  .then(() => {
    // Config loaded successfully, now it's safe to mount the app
    // All components can now use getRuntimeConfig() in their setup/created hooks
    app.mount("#app");
  })
  .catch((error) => {
    // Handle config loading failure gracefully
    // Log the error for debugging purposes
    console.error("Failed to initialize app:", error);

    // Show an error message to the user
    // The non-null assertion is safe because #app exists in index.html
    document.getElementById("app")!.innerHTML = `
      <div style="padding: 20px; text-align: center;">
        <h1>Configuration Error</h1>
        <p>Failed to load configuration. Please refresh the page.</p>
      </div>
    `;
  });
Enter fullscreen mode Exit fullscreen mode

Usage in Components

Once the app is mounted, any component can access the configuration synchronously using getRuntimeConfig().

<script setup lang="ts">
import { getRuntimeConfig } from "@/config/runtime-config";

// Access config in any component after app initialization
// This is safe because main.ts ensures config is loaded before mount
const config = getRuntimeConfig();

// Example: Use config values
const apiUrl = config.API_URL;
const isDev = config.ENV === "DEV";

// Example: Conditional feature based on environment
const showDebugTools = config.ENV !== "PRD";
</script>

<template>
  <div>
    <p>Current environment: {{ config.ENV }}</p>
    <p>API URL: {{ config.API_URL }}</p>

    <!-- Conditionally show debug info in non-production -->
    <pre v-if="showDebugTools">{{ JSON.stringify(config, null, 2) }}</pre>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

React

React applications load configuration before calling ReactDOM.createRoot().render(). This pattern ensures the entire component tree has access to configuration.

Key points:

  • Load config in index.tsx before rendering
  • Use getRuntimeConfig() directly in components, or
  • Wrap with Context for better testability and React patterns

index.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { loadRuntimeConfig } from "./config/runtime-config";

// Get the root element from index.html
// The non-null assertion is safe because this element must exist
const rootElement = document.getElementById("root")!;
const root = ReactDOM.createRoot(rootElement);

// Load runtime config before rendering the app
// This ensures config is available to all components from the first render
loadRuntimeConfig()
  .then(() => {
    // Config loaded successfully, render the app
    // StrictMode helps catch common issues during development
    root.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>
    );
  })
  .catch((error) => {
    // Handle config loading failure gracefully
    console.error("Failed to initialize app:", error);

    // Render an error message instead of the app
    // In production, you might want a more polished error boundary
    root.render(
      <div style={{ padding: "20px", textAlign: "center" }}>
        <h1>Configuration Error</h1>
        <p>Failed to load application configuration. Please refresh the page.</p>
      </div>
    );
  });
Enter fullscreen mode Exit fullscreen mode

Usage with Context (Optional)

For better React integration and testability, you can wrap the configuration in a Context. This makes it easy to mock config in tests and follows React patterns.

Benefits of using Context:

  • Easier to test (mock the provider)
  • Better React DevTools integration
  • Follows React's data flow patterns
  • Type-safe hook with clear error messages
import React, { createContext, useContext, ReactNode } from "react";
import { RuntimeConfig, getRuntimeConfig } from "./runtime-config";

// Create context with undefined default
// We use undefined (not null) to distinguish "not provided" from "explicitly null"
const ConfigContext = createContext<RuntimeConfig | undefined>(undefined);

/**
 * Provider component that supplies runtime config to the component tree
 *
 * IMPORTANT: Only use this after loadRuntimeConfig() has resolved
 * Typically wrap your App component with this in index.tsx
 *
 * @example
 * root.render(
 *   <ConfigProvider>
 *     <App />
 *   </ConfigProvider>
 * );
 */
export function ConfigProvider({ children }: { children: ReactNode }) {
  // Safe to call since we load config before rendering
  // If this throws, it means ConfigProvider was used before config loaded
  const config = getRuntimeConfig();

  return (
    <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
  );
}

/**
 * Hook to access runtime configuration in components
 *
 * @returns The runtime configuration object
 * @throws Error if used outside of ConfigProvider
 *
 * @example
 * function MyComponent() {
 *   const config = useConfig();
 *   return <div>Environment: {config.ENV}</div>;
 * }
 */
export function useConfig(): RuntimeConfig {
  const config = useContext(ConfigContext);

  // Provide a helpful error message if used incorrectly
  if (!config) {
    throw new Error("useConfig must be used within a ConfigProvider");
  }

  return config;
}
Enter fullscreen mode Exit fullscreen mode

Usage in Components

Components can access configuration either through the hook (if using Context) or directly via getRuntimeConfig().

import { useConfig } from "./config/ConfigContext";
// Or directly (if not using context):
// import { getRuntimeConfig } from './config/runtime-config';

/**
 * Example component showing config usage
 */
function MyComponent() {
  // Option 1: Using the hook (recommended if using Context)
  const config = useConfig();

  // Option 2: Direct access (simpler, but harder to test)
  // const config = getRuntimeConfig();

  // Example: Conditional rendering based on environment
  const showDebugInfo = config.ENV === "DEV";

  return (
    <div>
      <p>Current environment: {config.ENV}</p>
      <p>API URL: {config.API_URL}</p>

      {/* Only show in development */}
      {showDebugInfo && (
        <details>
          <summary>Debug Info</summary>
          <pre>{JSON.stringify(config, null, 2)}</pre>
        </details>
      )}
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

Angular

Angular uses the APP_INITIALIZER token to run code before the application bootstraps. This is the idiomatic way to load configuration in Angular applications.

Key points:

  • APP_INITIALIZER delays bootstrap until the Promise resolves
  • Works with both standalone components (Angular 17+) and NgModules
  • Configuration is available to all services and components from the start

app.config.ts (Angular 17+ standalone)

Angular 17 introduced a new standalone application architecture. Use this approach for new Angular applications.

import { ApplicationConfig, APP_INITIALIZER } from "@angular/core";
import { loadRuntimeConfig } from "./config/runtime-config";

/**
 * Factory function for APP_INITIALIZER
 *
 * Returns a function that loads the runtime config
 * Angular waits for the returned Promise to resolve before bootstrapping
 *
 * Note: The factory function returns another function (not a Promise directly)
 * This allows Angular to control when the initialization runs
 */
function initializeApp() {
  // Return a function that returns a Promise
  // Angular will call this function and wait for the Promise
  return () => loadRuntimeConfig();
}

/**
 * Application configuration
 * Passed to bootstrapApplication() in main.ts
 */
export const appConfig: ApplicationConfig = {
  providers: [
    // Register the APP_INITIALIZER to load config before app starts
    // Angular will wait for this to complete before rendering any components
    {
      provide: APP_INITIALIZER,
      useFactory: initializeApp,
      multi: true, // Allows multiple initializers to run in parallel
    },
    // Add other providers here (router, http, etc.)
  ],
};
Enter fullscreen mode Exit fullscreen mode

app.module.ts (Angular with NgModules)

For existing Angular applications using the traditional NgModule architecture.

import { NgModule, APP_INITIALIZER } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";
import { loadRuntimeConfig } from "./config/runtime-config";

/**
 * Factory function for APP_INITIALIZER
 *
 * Returns a function that loads the runtime config
 * The returned function must return a Promise
 */
function initializeApp() {
  return () => loadRuntimeConfig();
}

@NgModule({
  // Declare components that belong to this module
  declarations: [AppComponent],

  // Import other modules this module depends on
  imports: [BrowserModule],

  // Configure dependency injection
  providers: [
    // Register the APP_INITIALIZER to load config before app starts
    {
      provide: APP_INITIALIZER,
      useFactory: initializeApp,
      // Note: No deps needed since we're using the standalone loadRuntimeConfig
      multi: true, // Allows multiple initializers
    },
  ],

  // The root component to bootstrap
  bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Usage in Components

After the app initializes, any component can access the configuration using getRuntimeConfig().

import { Component } from "@angular/core";
import { getRuntimeConfig, RuntimeConfig } from "./config/runtime-config";

/**
 * Example component demonstrating config access
 *
 * The config is guaranteed to be loaded because APP_INITIALIZER
 * runs before any components are created
 */
@Component({
  selector: "app-example",
  template: `
    <div>
      <p>Current environment: {{ config.ENV }}</p>
      <p>API URL: {{ config.API_URL }}</p>

      <!-- Conditional content based on environment -->
      <div *ngIf="isDevelopment" class="debug-panel">
        <h3>Debug Info</h3>
        <pre>{{ config | json }}</pre>
      </div>
    </div>
  `,
})
export class ExampleComponent {
  // Load config once when component is created
  // Safe to call synchronously because APP_INITIALIZER has already run
  config: RuntimeConfig = getRuntimeConfig();

  // Computed property for template use
  get isDevelopment(): boolean {
    return this.config.ENV === "DEV";
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

Component Purpose
docker-entrypoint.sh Generates config.json from env vars at startup
Dockerfile Multi-stage build with Caddy for serving
Caddyfile Web server config with SPA routing support
runtime-config.ts Fetches and caches runtime config in your app
main.ts / index.tsx Loads config before app initialization

Framework-Specific Notes

Framework Build Output Dir Base URL Variable Init Location
Vue (Vite) dist import.meta.env.BASE_URL main.ts
React CRA build process.env.PUBLIC_URL index.tsx
React Vite dist import.meta.env.BASE_URL index.tsx
Angular dist/<project-name> Set in angular.json baseHref app.config.ts
Native JS varies / or configure manually main.js

Troubleshooting

Config not loading

  • Check that config.json exists in the served directory (/srv/)
  • Verify the entrypoint script has execute permissions (chmod +x)
  • Check browser console for fetch errors (404, CORS, etc.)

Environment variables not appearing

  • Ensure variables are passed to the container (docker run -e VAR=value)
  • Check the generated config.json inside the container
  • Verify variable names match between entrypoint and interface

SPA routing not working

  • Confirm try_files {path} /index.html is in your Caddyfile
  • Check that the web server is actually serving index.html for unknown routes

This approach allows you to build your application once and deploy it to multiple environments, with configuration injected at runtime via environment variables.

Top comments (0)