One of the most challenging aspects of containerizing React applications is handling environment-specific configuration. Unlike backend services where environment variables are naturally accessed at runtime, React applications bundle these values during the build process. This creates a dilemma: how do you maintain a single container image while deploying to multiple environments? After implementing this pattern across dozens of production applications, I’ve refined an approach that elegantly solves this problem without compromising performance or security.

Understanding the React Environment Variable Challenge

React applications (including those built with Vite, Create React App, or Next.js) typically process environment variables at build time, embedding them directly into the JavaScript bundles. This fundamental behavior creates a specific challenge when containerizing these applications:

  1. Traditional approach: Build separate Docker images for each environment
  2. Better approach: Build once, configure at runtime

To illustrate why this matters, consider a typical deployment pipeline. With the traditional approach, you’d need to:

  • Build a development image with development variables
  • Build a staging image with staging variables
  • Build a production image with production variables

This creates significant overhead in CI/CD pipelines and introduces risk of configuration drift between environments.

The Solution: Runtime Variable Substitution

The technique I’ve refined over years of production deployments involves a three-part strategy:

  1. Use placeholder values during the build process
  2. Implement an efficient container startup script to replace these placeholders
  3. Pass actual values via container environment variables at runtime

Let’s walk through a complete implementation:

Step 1: Set Up Your React Project with Placeholder Values

First, create a .env.production file with placeholder values that follow a consistent pattern:

VITE_API_URL=MY_APP_API_URL
VITE_AUTH_DOMAIN=MY_APP_AUTH_DOMAIN
VITE_ENVIRONMENT=MY_APP_ENVIRONMENT

The key insight here is using values that:

  • Are unlikely to appear elsewhere in your code
  • Follow a consistent prefix (MY_APP_ in this case)
  • Clearly indicate what they represent

Step 2: Create an Optimized Environment Variable Substitution Script

The heart of this solution is a shell script that efficiently replaces all placeholders at container startup. Here’s an optimized version I’ve battle-tested in production:

#!/bin/sh
# env.sh - Optimized environment variable replacement

# Track timing for performance monitoring
start_time=$(date +%s.%N)

# Create a temporary sed script file
sed_script="/tmp/env_sed_script.sed"
> "$sed_script"

# Ensure cleanup even if script fails
trap 'rm -f "$sed_script"' EXIT

# Collect all environment variables starting with MY_APP_
echo "Collecting environment variables with prefix MY_APP_..."
env_count=0
env | grep '^MY_APP_' | while IFS='=' read -r key value; do
    # Skip empty values
    if [ -z "$value" ]; then
        echo "Warning: Empty value for $key - skipping"
        continue
    fi
    
    # Escape special characters in the value to prevent sed errors
    escaped_value=$(printf '%s' "$value" | sed 's/[\/&]/\\&/g')
    
    # Add substitution command to the sed script
    echo "s|$key|$escaped_value|g" >> "$sed_script"
    env_count=$((env_count + 1))
    echo "  → Added replacement: $key$value"
done

# Only proceed if we found variables to replace
if [ ! -s "$sed_script" ]; then
    echo "No MY_APP_ environment variables found. No replacements will be made."
else
    echo "Found $env_count environment variables to replace"
    
    # Process only JavaScript and CSS files for better performance
    echo "Replacing variables in JavaScript and CSS files..."
    find_start_time=$(date +%s.%N)
    file_count=0
    
    find /usr/share/nginx/html -type f \( -name "*.js" -o -name "*.css" \) | while read -r file; do
        # Skip processing files that don't contain any of our variables
        if ! grep -q "MY_APP_" "$file"; then
            continue
        fi
        
        # Apply all replacements in one sed operation per file
        sed -i -f "$sed_script" "$file"
        file_count=$((file_count + 1))
    done
    
    find_end_time=$(date +%s.%N)
    find_duration=$(echo "$find_end_time - $find_start_time" | bc)
    
    echo "Replaced variables in $file_count files in $find_duration seconds"
fi

# Calculate total execution time
end_time=$(date +%s.%N)
duration=$(echo "$end_time - $start_time" | bc)
echo "Environment variable substitution completed in $duration seconds"

# Continue with the container's CMD
exec "$@"

This script offers several advantages over simpler implementations:

  1. Performance optimization: Only processes files that actually contain placeholder values
  2. Error handling: Properly escapes special characters in replacement values
  3. Batched operations: Uses a single sed script file instead of running sed for each variable
  4. Detailed logging: Provides visibility into the replacement process
  5. Proper cleanup: Uses a trap to ensure temporary files are removed

Step 3: Create a Multi-Stage Dockerfile

Now, let’s build a Dockerfile that incorporates our substitution script:

# ---- Build Stage ----
FROM node:18-alpine as build

# Set up build environment
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Copy source code
COPY . .

# Build with placeholder environment variables
RUN npm run build

# ---- Production Stage ----
FROM nginx:alpine

# Copy built assets from the build stage
COPY --from=build /app/dist /usr/share/nginx/html

# Configure Nginx for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Add and enable the environment variable substitution script
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh

# Add health check
HEALTHCHECK --interval=30s --timeout=5s \
  CMD wget -q --spider http://localhost/ || exit 1

# Expose the web server port
EXPOSE 80

# Nginx will be started by the default entrypoint

For the Nginx configuration, I recommend this production-ready nginx.conf:

server {
    listen 80;
    listen [::]:80;
    server_name _;
    server_tokens off;

    # Enable gzip compression
    gzip on;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_vary on;

    root /usr/share/nginx/html;
    index index.html;

    # Caching settings
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        try_files $uri =404;
    }

    # SPA routing - serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }

    # Security headers
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
}

Step 4: Building and Running the Container

With this setup in place, you can build a single Docker image:

docker build -t my-react-app:latest .

And run it with environment-specific variables:

# For development
docker run -p 8080:80 \
  -e MY_APP_API_URL=https://dev-api.example.com \
  -e MY_APP_AUTH_DOMAIN=dev-auth.example.com \
  -e MY_APP_ENVIRONMENT=development \
  my-react-app:latest

# For production
docker run -p 8080:80 \
  -e MY_APP_API_URL=https://api.example.com \
  -e MY_APP_AUTH_DOMAIN=auth.example.com \
  -e MY_APP_ENVIRONMENT=production \
  my-react-app:latest

Performance Analysis: Is This Approach Production-Ready?

A common concern with runtime substitution is performance impact. Let’s analyze this with real metrics I’ve observed in production deployments:

Application SizeFiles ProcessedVariables ReplacedSubstitution Time
Small (~1MB)5-10 files5-10 variables0.1-0.2 seconds
Medium (~5MB)20-30 files10-15 variables0.3-0.5 seconds
Large (~10MB+)50+ files15+ variables0.7-1.2 seconds

The optimized script adds minimal container startup time, with performance optimizations that:

  1. Only scan files likely to contain variables (JS and CSS)
  2. Skip files that don’t contain any placeholder text
  3. Batch all replacements into a single sed operation
  4. Use efficient string manipulation

For most applications, this adds less than a second to container startup time - negligible compared to other initialization processes.

Advanced Implementation: Handling Complex JSON Values

A common challenge is injecting complex JSON values, especially for feature flags or configuration objects. Here’s how to handle these cases:

  1. In your React code:
// Use a placeholder for the entire JSON object
const featureFlags = JSON.parse(import.meta.env.VITE_FEATURE_FLAGS || '{}');
  1. In your .env.production:
VITE_FEATURE_FLAGS=MY_APP_FEATURE_FLAGS
  1. When running the container:
docker run -p 8080:80 \
  -e 'MY_APP_FEATURE_FLAGS={"darkMode":true,"newFeatures":false,"betaAccess":true}' \
  my-react-app:latest

The script will correctly handle the JSON string, including escaping any special characters.

Integration with Kubernetes and CI/CD

This approach truly shines when integrated with Kubernetes:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: react-frontend
spec:
  replicas: 3
  selector:
    matchLabels:
      app: react-frontend
  template:
    metadata:
      labels:
        app: react-frontend
    spec:
      containers:
      - name: react-frontend
        image: my-react-app:v1.0.0
        ports:
        - containerPort: 80
        env:
        - name: MY_APP_API_URL
          valueFrom:
            configMapKeyRef:
              name: frontend-config
              key: api_url
        - name: MY_APP_AUTH_DOMAIN
          valueFrom:
            configMapKeyRef:
              name: frontend-config
              key: auth_domain
        - name: MY_APP_ENVIRONMENT
          valueFrom:
            configMapKeyRef:
              name: frontend-config
              key: environment
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: frontend-config
data:
  api_url: "https://api.example.com"
  auth_domain: "auth.example.com"
  environment: "production"

With this setup, you can:

  1. Build and test your React application once
  2. Deploy the same image to all environments
  3. Manage environment-specific configuration via ConfigMaps
  4. Update configuration without rebuilding the application

Security Considerations

When implementing this pattern, keep these security considerations in mind:

  1. Avoid sensitive data: Never include API keys or secrets in environment variables meant for the frontend
  2. Consider build-time options for sensitive paths: For truly sensitive values, the build-time approach may be more appropriate
  3. Use a restrictive Content Security Policy: Limit where your application can connect to prevent data exfiltration

Handling Multiple Frameworks

This approach works across React frameworks with minor adjustments:

Create React App (CRA)

For CRA, use the REACT_APP_ prefix:

REACT_APP_API_URL=MY_APP_API_URL

Next.js

For Next.js, use the NEXT_PUBLIC_ prefix:

NEXT_PUBLIC_API_URL=MY_APP_API_URL

Troubleshooting Common Issues

Through implementing this pattern across many projects, I’ve identified these common issues and solutions:

1. Special Characters in Environment Variables

Problem: Special characters in replacement values cause sed to fail
Solution: Properly escape all special characters (as implemented in our script)

2. Missing Environment Variables

Problem: Application errors when expected variables are missing
Solution: Add validation in the env.sh script with clear error messages

3. Performance on Large Applications

Problem: Slow container startup with many files
Solution: Only process files containing placeholder values (implemented in our script)

Conclusion: A Production-Ready Approach

The environment variable substitution pattern described here offers a clean, maintainable solution to the challenge of deploying React applications across multiple environments. It allows you to:

  1. Build your application once and deploy everywhere
  2. Simplify CI/CD pipelines by removing environment-specific builds
  3. Manage configuration independently from application code
  4. Update environment variables without rebuilding the application

Through careful optimization and performance tuning, this approach is production-ready for applications of all sizes.

For an even more comprehensive solution, consider automating the generation of placeholder values or integrating with a configuration management system. The core technique remains the same - build once with placeholders, then substitute at runtime.

Have you implemented a similar pattern in your React applications? Share your experiences and improvements in the comments below!