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
- Build phase: Your app is built without environment-specific values
-
Container startup: A shell script generates
config.jsonfrom environment variables -
App initialization: Your frontend fetches
config.jsonbefore rendering
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Docker Build │ │ Container Start │ │ App Loads │
│ │ │ │ │ │
│ npm run build │ ──▶ │ entrypoint.sh │ ──▶ │ fetch config.json│
│ (no env vars) │ │ creates config │ │ then render │
└─────────────────┘ └─────────────────┘ └─────────────────┘
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
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"]
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
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";
}
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;
}
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
}
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.tsbeforeapp.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>
`;
});
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>
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.tsxbefore 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>
);
});
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;
}
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;
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_INITIALIZERdelays 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.)
],
};
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 {}
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";
}
}
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.jsonexists 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.jsoninside the container - Verify variable names match between entrypoint and interface
SPA routing not working
- Confirm
try_files {path} /index.htmlis 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)