React + Docker: Environment Variables & Secrets
React build-time variables (like REACT_APP_API_URL) are baked into the compiled bundle during npm run build, so they can't be changed after the Docker image is built. Runtime variables (for Node.js servers inside containers) can be injected at container start. Mixing these up causes a common bug: deploying the same image to staging and production, but production loads staging's API URL. This article clarifies the distinction, shows how to handle both, and covers secure secret management using Docker secrets, environment files, and external vaults like HashiCorp Vault.
I've debugged production incidents caused by misconfigured environment variables costing teams 2+ hours of downtime. Proper secrets management prevents this: separate images for each environment, inject runtime secrets at container start, and audit who accesses what.
React build-time vs runtime variables
Build-time variables: Embedded in the React bundle during npm run build. Examples:
REACT_APP_API_URL→https://api.example.com(baked intoapp-abc123.js)REACT_APP_ENV→production(visible in minified JS)
React's build process substitutes these at compile time. Once compiled, they're immutable—changing them requires rebuilding and redeploying.
Runtime variables: Available in Node.js processes running inside the container. Examples:
DATABASE_URL→ connection string (only Node.js knows, not shipped to browser)JWT_SECRET→ signing key (server-side only)
The Dockerfile pattern is:
- Build stage: Set
REACT_APP_*build-time args beforenpm run build. - Runtime stage: Pass Node.js environment variables at
docker runtime.
Here's a revised Dockerfile showing both:
# Build stage: inject build-time variables
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Accept build-time args (docker build --build-arg REACT_APP_API_URL=...)
ARG REACT_APP_API_URL=https://api.example.com
ARG REACT_APP_ENV=production
# Set as ENV so npm run build can access them
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_ENV=$REACT_APP_ENV
RUN npm run build
# Runtime stage: inject runtime variables at docker run time
FROM node:18-alpine
WORKDIR /app
# Install a small server (or use your Node.js app)
RUN npm install -g serve
COPY --from=builder /app/build ./build
EXPOSE 3000
# Runtime variables are passed via docker run -e or docker-compose
CMD ["serve", "-s", "build", "-l", "3000"]
Build for staging:
docker build \
--build-arg REACT_APP_API_URL=https://staging-api.example.com \
--build-arg REACT_APP_ENV=staging \
-t my-app:staging .
Build for production (different image, same Dockerfile):
docker build \
--build-arg REACT_APP_API_URL=https://api.example.com \
--build-arg REACT_APP_ENV=production \
-t my-app:production .
Now you have two images with different build-time variables baked in. This is the correct pattern: one image per environment.
Runtime environment variables with docker run
If your container runs a Node.js API, pass environment variables at runtime:
docker run \
-e DATABASE_URL=postgresql://user:pass@db:5432/prod \
-e JWT_SECRET=your-secret-key \
-e NODE_ENV=production \
-p 3000:3000 \
my-app:production
Inside the container, your Node.js code accesses them:
const dbUrl = process.env.DATABASE_URL;
const jwtSecret = process.env.JWT_SECRET;
console.log(`Connecting to ${dbUrl}`);
This pattern works because process.env is runtime state, not compile-time. You can start the same image with different variables for different environments:
# Staging
docker run -e DATABASE_URL=postgres://staging-db ... my-app:production
# Production
docker run -e DATABASE_URL=postgres://prod-db ... my-app:production
Docker Compose with environment files
For multi-container setups (React app + API + database), use docker-compose.yml with .env files:
# docker-compose.yml
version: '3.8'
services:
react-app:
build:
context: .
dockerfile: Dockerfile
args:
REACT_APP_API_URL: ${REACT_APP_API_URL}
REACT_APP_ENV: ${REACT_APP_ENV}
ports:
- "3000:3000"
environment:
- NODE_ENV=${NODE_ENV}
api:
build: ./api
ports:
- "5000:5000"
environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Create .env.staging:
REACT_APP_API_URL=https://staging-api.example.com
REACT_APP_ENV=staging
NODE_ENV=staging
DATABASE_URL=postgresql://user:pass@db:5432/staging
JWT_SECRET=staging-secret
POSTGRES_DB=staging_db
POSTGRES_PASSWORD=staging_password
Run staging:
docker-compose --env-file .env.staging up
Create .env.production (never commit to Git):
REACT_APP_API_URL=https://api.example.com
REACT_APP_ENV=production
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@prod-db:5432/production
JWT_SECRET=<actual-secret-from-vault>
POSTGRES_DB=production_db
POSTGRES_PASSWORD=<actual-password-from-vault>
Run production:
docker-compose --env-file .env.production up
Important: Never commit .env.production to Git. Store it in a secure vault (HashiCorp Vault, AWS Secrets Manager) and pull it at deployment time.
Docker secrets (swarm mode)
For Docker Swarm deployments, use built-in secrets:
# Create a secret (read from file or stdin)
echo "actual-jwt-secret" | docker secret create jwt_secret -
# Create another
echo "actual-db-password" | docker secret create db_password -
In docker-compose.yml:
services:
api:
build: ./api
environment:
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- DATABASE_PASSWORD_FILE=/run/secrets/db_password
secrets:
- jwt_secret
- db_password
secrets:
jwt_secret:
external: true
db_password:
external: true
Docker mounts secrets as files in /run/secrets/ (read-only, 600 permissions). Your app reads the file:
const fs = require('fs');
const jwtSecret = fs.readFileSync('/run/secrets/jwt_secret', 'utf-8').trim();
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf-8').trim();
Secrets are encrypted on disk and in transit. Only services with secrets: access can read them.
Using external vaults (HashiCorp Vault, AWS Secrets Manager)
For production multi-cloud deployments, use an external vault. The container pulls secrets at startup. Example with HashiCorp Vault:
FROM node:18-alpine
WORKDIR /app
# Install curl and jq for secret fetching
RUN apk add --no-cache curl jq
COPY --from=builder /app/build ./build
# Startup script that fetches secrets and sets env vars
COPY scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["serve", "-s", "build", "-l", "3000"]
scripts/entrypoint.sh:
#!/bin/sh
set -e
# Fetch JWT secret from Vault
JWT_SECRET=$(curl -s -H "X-Vault-Token: $VAULT_TOKEN" \
"http://vault:8200/v1/secret/data/react-app/jwt" | jq -r '.data.data.secret')
# Export for the container
export JWT_SECRET=$JWT_SECRET
# Start the app
exec "$@"
At container start:
docker run \
-e VAULT_TOKEN=s.xxxxxx \
-e VAULT_ADDR=http://vault:8200 \
my-app:production
The entrypoint fetches secrets from Vault, then starts the app. Secrets never exist as environment variables in the Dockerfile or image—they're fetched at runtime only.
Security best practices
Never commit secrets to Git:
# .gitignore
.env.production
.env.local
secrets/
Use secret management tools: HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets, or Azure Key Vault—not plaintext files.
Rotate secrets regularly: Use short-lived credentials (JWT tokens valid for 1 hour, database passwords rotated monthly).
Audit access: Enable audit logs in Vault/Secrets Manager to see who accessed what secret and when.
Different secrets per environment: staging and production should have separate keys, so a compromised staging secret doesn't expose production.
Container image scanning: Use tools like Trivy to scan images for embedded secrets:
trivy image my-app:latest
Key Takeaways
- React build-time variables (
REACT_APP_*) are embedded in the bundle; change them viadocker build --build-arg. - Runtime variables are injected at
docker runor via Docker Compose environment files. - Create separate images per environment with different build-time args.
- Use Docker Swarm secrets or external vaults (Vault, AWS Secrets Manager) for sensitive data.
- Never commit
.env.productionor secrets files to Git.
Frequently Asked Questions
Can I change REACT_APP_* variables without rebuilding?
No, they're baked into the bundle at build time. Rebuilding the Docker image is required. This is intentional—it prevents misconfigurations where staging code accidentally uses production API URLs.
How do I debug environment variables in a running container?
Use docker exec: docker exec <container-id> sh -c 'echo $DATABASE_URL' (or env for all). For build-time vars, inspect the minified bundle or add console.log(process.env.REACT_APP_API_URL) in your React app (remove before production).
Can I use a .env file inside a Docker image?
Not recommended. Files in the image are immutable and visible to anyone who can access the image. Use runtime injection (docker run -e, Compose, or vaults) instead.
What if I need different React build-time config but don't want to rebuild?
This is a limitation of build-time variables. Solutions: (1) Rebuild for each config (correct). (2) Use a runtime API endpoint that returns config (less ideal, adds latency). (3) Use CSS custom properties / feature flags fetched at runtime (workaround, not for sensitive data).
How do I handle secrets in CI/CD (GitHub Actions, GitLab CI)?
Store secrets in the CI/CD platform's secret manager (GitHub Actions > Settings > Secrets; GitLab CI > Settings > CI/CD > Protected variables). Access them in the pipeline: docker build --build-arg JWT_SECRET=${{ secrets.JWT_SECRET }} (GitHub Actions syntax). The CI/CD platform hides them from logs.