Dockerizing React Applications: Runtime vs. Build-time Environment Variables
When containerizing React applications for deployment across multiple environments, one of the most critical architectural decisions is how to handle environment-specific configuration. After implementing both approaches across numerous production deployments, I’ve developed strong opinions about the tradeoffs between injecting environment variables at build time versus runtime. This post dives into both methods with practical examples and explains which approach I recommend for most production scenarios.
The Environment Variable Challenge in React Applications
React applications, like most modern frontend frameworks, typically bundle environment variables during the build process. This presents a unique challenge when containerizing these applications:
- Build-time variables: Values are embedded into the JavaScript bundle during the build process (
npm run buildor equivalent) - Runtime variables: Values need to be injected after the app is built, which requires additional techniques
This distinction becomes crucial when deploying the same application across development, staging, and production environments, especially within a containerized workflow.
Approach 1: Build-Time Environment Variable Injection
With this approach, environment variables are injected during the Docker image build process using build arguments (--build-arg). These values are then permanently embedded in the JavaScript bundle.
Implementation Example
Here’s a Dockerfile that demonstrates this approach:
# Build stage
FROM node:18-alpine as build
# Define build arguments
ARG REACT_APP_API_URL
ARG REACT_APP_FEATURE_FLAGS
# Set environment variables for the build process
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_FEATURE_FLAGS=$REACT_APP_FEATURE_FLAGS
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
To build this image for different environments:
# For development
docker build \
--build-arg REACT_APP_API_URL=https://dev-api.example.com \
--build-arg REACT_APP_FEATURE_FLAGS='{"newFeature":true}' \
-t myapp:dev .
# For production
docker build \
--build-arg REACT_APP_API_URL=https://api.example.com \
--build-arg REACT_APP_FEATURE_FLAGS='{"newFeature":false}' \
-t myapp:prod .
Running the container is straightforward since all configuration is already baked in:
docker run -p 80:80 myapp:prod
Pros of Build-Time Injection
- Simplicity: No additional runtime scripts or complexity needed
- Immutability: Each environment has its own immutable image, preventing configuration drift
- Security: Sensitive variables aren’t exposed in the container environment
- Performance: No runtime processing overhead
- Validation: Configuration issues are caught during build, not at runtime
Cons of Build-Time Injection
- Image Proliferation: Requires building and storing separate images for each environment
- CI/CD Complexity: Pipeline needs to build multiple versions of the same application
- Flexibility Limitations: Configuration changes require rebuilding and redeploying
Approach 2: Runtime Environment Variable Injection
This approach involves building a single Docker image and injecting environment variables when the container starts. Since React applications bundle environment variables at build time, this requires an additional runtime script to modify the JavaScript files.
Implementation Example
First, we need a script to replace placeholders in the bundled JavaScript files at container startup:
#!/bin/sh
# env.sh - Script to replace placeholders with environment variables
# Process .js files
echo "Replacing environment variables in JS files..."
for file in /usr/share/nginx/html/static/js/*.js; do
# Replace PLACEHOLDER_API_URL with actual environment variable
if [ ! -z "$REACT_APP_API_URL" ]; then
sed -i "s|PLACEHOLDER_API_URL|$REACT_APP_API_URL|g" $file
fi
# Replace PLACEHOLDER_FEATURE_FLAGS with actual environment variable
if [ ! -z "$REACT_APP_FEATURE_FLAGS" ]; then
# Escape special characters in JSON
ESCAPED_FLAGS=$(echo $REACT_APP_FEATURE_FLAGS | sed 's/\//\\\//g')
sed -i "s|PLACEHOLDER_FEATURE_FLAGS|$ESCAPED_FLAGS|g" $file
fi
done
echo "Environment variable replacement complete"
# Start nginx
exec "$@"
Then, our Dockerfile needs to include this script and use it as an entrypoint:
# Build stage
FROM node:18-alpine as build
# Define placeholder values for the build
ENV REACT_APP_API_URL=PLACEHOLDER_API_URL
ENV REACT_APP_FEATURE_FLAGS=PLACEHOLDER_FEATURE_FLAGS
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Notice that we’re using special placeholder values (PLACEHOLDER_API_URL) during build. The runtime script will replace these values with actual environment variables when the container starts.
To build the image:
docker build -t myapp:latest .
And to run it with different environments:
# For development
docker run -p 80:80 \
-e REACT_APP_API_URL=https://dev-api.example.com \
-e REACT_APP_FEATURE_FLAGS='{"newFeature":true}' \
myapp:latest
# For production
docker run -p 80:80 \
-e REACT_APP_API_URL=https://api.example.com \
-e REACT_APP_FEATURE_FLAGS='{"newFeature":false}' \
myapp:latest
Pros of Runtime Injection
- Single Image: Build once, deploy anywhere with different environment variables
- Flexible Configuration: Change variables without rebuilding the image
- Simplified CI/CD: Only need to build and test one image
- Dynamic Updates: Environment variables can be changed without redeployment
Cons of Runtime Injection
- Complexity: Requires additional scripts and understanding of how to modify built files
- Performance Impact: Small startup delay due to file processing
- Limited to String Replacements: Complex data structures might be challenging to replace correctly
- Error Prone: Runtime errors if replacements fail or variables are missing
Which Approach Should You Choose?
After implementing both approaches across various React applications, I’ve developed a framework for deciding which method to use:
Choose Build-Time Injection When:
- Security is paramount: For applications with sensitive configuration (like authentication endpoints)
- Environment count is small: If you only have 2-3 stable environments
- Configuration rarely changes: For stable applications with infrequent config updates
- Validation is critical: When you want to ensure all environment variables are present at build time
Choose Runtime Injection When:
- Environments proliferate: When you have many environments or dynamic environment creation
- Configuration changes frequently: For applications under active development
- CI/CD pipeline optimization is important: To reduce build times and artifacts
- Dynamic deployment is needed: For multi-tenant applications or customizable deployments
Real-World Implementation: A Hybrid Approach
In production applications, I often implement a hybrid approach that provides the best of both worlds:
# Build stage
FROM node:18-alpine as build
# Build arguments for base configuration that rarely changes
ARG REACT_APP_VERSION
ARG REACT_APP_BUILD_DATE
# Environment variables that should be replaceable at runtime
ENV REACT_APP_VERSION=$REACT_APP_VERSION
ENV REACT_APP_BUILD_DATE=$REACT_APP_BUILD_DATE
ENV REACT_APP_API_URL=PLACEHOLDER_API_URL
ENV REACT_APP_AUTH_DOMAIN=PLACEHOLDER_AUTH_DOMAIN
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -q --spider http://localhost/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
This approach:
- Bakes in build-specific information at build time (version, build date)
- Uses placeholder values for environment-specific configuration
- Replaces placeholders at runtime with actual environment variables
The environment replacement script is enhanced to provide better error handling:
#!/bin/sh
# env.sh - Script to replace placeholders with environment variables
# Required environment variables
REQUIRED_VARS="REACT_APP_API_URL REACT_APP_AUTH_DOMAIN"
# Check for required variables
for var in $REQUIRED_VARS; do
if [ -z "$(eval echo \$$var)" ]; then
echo "Error: Required environment variable $var is not set!"
exit 1
fi
done
# Process .js files
echo "Replacing environment variables in JS files..."
find /usr/share/nginx/html -type f -name "*.js" | while read file; do
# Replace each placeholder with its environment variable
sed -i "s|PLACEHOLDER_API_URL|$REACT_APP_API_URL|g" $file
sed -i "s|PLACEHOLDER_AUTH_DOMAIN|$REACT_APP_AUTH_DOMAIN|g" $file
done
echo "Environment variable replacement complete"
# Execute the original command
exec "$@"
Optimizing for Kubernetes Deployments
When deploying React applications in Kubernetes, the runtime injection approach offers additional advantages:
- ConfigMaps and Secrets: Environment variables can be managed through Kubernetes resources
- Rolling Updates: Configuration changes can be applied without rebuilding images
- Resource Efficiency: Fewer images to store and manage
Here’s an example Kubernetes deployment using runtime configuration:
apiVersion: apps/v1
kind: Deployment
metadata:
name: react-app
spec:
replicas: 3
selector:
matchLabels:
app: react-app
template:
metadata:
labels:
app: react-app
spec:
containers:
- name: react-app
image: myapp:latest
ports:
- containerPort: 80
env:
- name: REACT_APP_API_URL
valueFrom:
configMapKeyRef:
name: react-app-config
key: api_url
- name: REACT_APP_AUTH_DOMAIN
valueFrom:
configMapKeyRef:
name: react-app-config
key: auth_domain
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: react-app-config
data:
api_url: "https://api.example.com"
auth_domain: "auth.example.com"
Performance Considerations
There’s a common concern about the performance impact of runtime environment variable injection. In practice, I’ve found this impact to be negligible for most applications:
- Container Startup: The script adds ~100-200ms to container startup time
- File Processing: Modern servers can process the file replacements very quickly
- Caching: Once the files are processed, they’re served from the filesystem as normal
In Kubernetes environments where pod startup might already take several seconds, this small additional delay is rarely noticeable.
Monitoring and Debugging
With runtime injection, it’s important to add proper monitoring:
- Container Logs: The replacement script should log its activity
- Health Checks: Add a healthcheck that verifies critical configuration
- Version Information: Include a
/versionor health endpoint that displays current configuration (without sensitive values)
Conclusion: My Recommended Approach
After working with both methods across multiple production applications, I generally recommend the runtime injection approach for most React applications deployed in container environments, especially those using Kubernetes or other orchestration platforms.
The benefits of flexibility, CI/CD simplicity, and operational efficiency typically outweigh the small additional complexity. The hybrid approach I’ve outlined provides a good balance by baking in truly static configuration at build time while allowing environment-specific values to be injected at runtime.
That said, for applications with extremely sensitive configuration or those with only a couple of stable environments, the build-time approach remains a valid and sometimes preferable option.
Whichever method you choose, documenting your approach and ensuring all team members understand how configuration flows through your application is essential for maintaining a stable and secure deployment process.
Have you implemented either of these approaches in your React applications? I’d be interested to hear about your experiences in the comments below.