Introduction
Managing environment variables across different environments (development, staging, production) is a critical aspect of modern web application development. When working with Vite, React, and Docker together, understanding how these tools handle environment variables can save you hours of debugging and ensure your application behaves correctly in all environments.
In this comprehensive guide, we'll explore how to effectively use environment variables across your entire stack, with practical examples drawn from real-world scenarios.
Table of Contents
- Understanding Environment Variables
- Vite Environment Variables
- React and TypeScript Integration
- Docker Environment Variables
- Multi-Stage Docker Builds
- Best Practices
- Common Pitfalls and Solutions
- Complete Example
Understanding Environment Variables
Environment variables allow you to configure your application differently for various environments without changing the code. They're essential for:
- API Endpoints: Different URLs for dev/staging/production
- Feature Flags: Enable/disable features per environment
- Secrets: API keys, tokens (though never commit these!)
- Build Configuration: Optimize builds differently per environment
Key Concepts
# Format: KEY=VALUE
API_URL=https://api.example.com
ENABLE_ANALYTICS=true
MAX_UPLOAD_SIZE=10485760
Vite Environment Variables
Vite has a built-in system for handling environment variables that's both powerful and secure.
Vite's Environment File Structure
my-app/
βββ .env # Loaded in all cases
βββ .env.local # Loaded in all cases, ignored by git
βββ .env.development # Only loaded in development
βββ .env.production # Only loaded in production
βββ .env.staging # Custom environment
The VITE_ Prefix Requirement
VITE_ are exposed to your client-side code.
# β NOT exposed to client (for build-time only)
API_SECRET=my-secret-key
DATABASE_URL=postgresql://...
# β
Exposed to client
VITE_API_URL=https://api.example.com
VITE_APP_NAME=My Awesome App
VITE_ENABLE_ANALYTICS=true
Example Configuration Files
# Development environment configuration
VITE_API_URL=http://localhost:5000
VITE_WIDGET_URL=http://localhost:3001
VITE_PORTAL_URL=http://localhost:3000
VITE_ENABLE_DEBUG=true
VITE_ANALYTICS_ID=
# Production environment configuration
VITE_API_URL=https://api.myapp.com
VITE_WIDGET_URL=https://cdn.myapp.com/widget.js
VITE_PORTAL_URL=https://portal.myapp.com
VITE_ENABLE_DEBUG=false
VITE_ANALYTICS_ID=G-XXXXXXXXXX
Accessing Variables in Code
// β This will be undefined!
const apiUrl = process.env.API_URL;
// β
Correct way with Vite
const apiUrl = import.meta.env.VITE_API_URL;
// With fallback
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000';
React and TypeScript Integration
Type-Safe Environment Variables
Create a type definition file to get autocomplete and type checking:
/// <reference types="vite/client" />
interface ImportMetaEnv {
// API Configuration
readonly VITE_API_URL: string;
readonly VITE_API_TIMEOUT: string;
// Feature Flags
readonly VITE_ENABLE_ANALYTICS: string;
readonly VITE_ENABLE_DEBUG: string;
// Third-party Services
readonly VITE_GOOGLE_MAPS_KEY: string;
readonly VITE_STRIPE_PUBLIC_KEY: string;
// OAuth Providers
readonly VITE_GOOGLE_CLIENT_ID: string;
readonly VITE_GITHUB_CLIENT_ID: string;
// URLs
readonly VITE_PORTAL_URL: string;
readonly VITE_WIDGET_URL: string;
readonly VITE_CDN_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Creating a Configuration Hook
import { useMemo } from 'react';
interface AppConfig {
apiUrl: string;
portalUrl: string;
widgetUrl: string;
enableAnalytics: boolean;
enableDebug: boolean;
googleMapsKey?: string;
}
export function useConfig(): AppConfig {
return useMemo(() => ({
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:5000',
portalUrl: import.meta.env.VITE_PORTAL_URL || 'http://localhost:3000',
widgetUrl: import.meta.env.VITE_WIDGET_URL || 'http://localhost:3001',
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
enableDebug: import.meta.env.VITE_ENABLE_DEBUG === 'true',
googleMapsKey: import.meta.env.VITE_GOOGLE_MAPS_KEY,
}), []);
}
Using the Configuration
import { useConfig } from '../hooks/useConfig';
export function MyComponent() {
const config = useConfig();
const fetchData = async () => {
const response = await fetch(`${config.apiUrl}/api/data`);
return response.json();
};
return (
<div>
{config.enableDebug && (
<div className="debug-info">
API: {config.apiUrl}
</div>
)}
{/* Component content */}
</div>
);
}
Docker Environment Variables
Docker provides multiple ways to pass environment variables to containers.
Method 1: Using .env Files with Docker Compose
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL}
- VITE_PORTAL_URL=${VITE_PORTAL_URL}
env_file:
- .env.prod
environment:
- NODE_ENV=production
ports:
- "3000:80"
VITE_API_URL=https://api.myapp.com
VITE_PORTAL_URL=https://portal.myapp.com
VITE_WIDGET_URL=https://cdn.myapp.com/widget.js
VITE_ANALYTICS_ID=G-XXXXXXXXXX
Method 2: Build Arguments vs Runtime Environment Variables
- Build Args (
ARG): Used during image build, baked into the image - Environment Variables (
ENV): Available at runtime, can be overridden
FROM node:18-alpine AS build
# Build arguments (passed during build)
ARG VITE_API_URL
ARG VITE_PORTAL_URL
ARG VITE_WIDGET_URL
# Set as environment variables for the build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_PORTAL_URL=$VITE_PORTAL_URL
ENV VITE_WIDGET_URL=$VITE_WIDGET_URL
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Build the app (environment variables are now available)
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Building with Specific Environment
# Development
docker compose --env-file .env.dev up -d --build
# Production
docker compose --env-file .env.prod up -d --build
# Staging
docker compose --env-file .env.staging up -d --build
Multi-Stage Docker Builds
Multi-stage builds allow you to create optimized production images.
Complete Multi-Stage Dockerfile
# ============================================
# Stage 1: Build
# ============================================
FROM node:18-alpine AS build
# Build arguments
ARG VITE_API_URL
ARG VITE_PORTAL_URL
ARG VITE_WIDGET_URL
ARG VITE_ANALYTICS_ID
ARG VITE_ENABLE_DEBUG=false
# Set environment variables for Vite
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_PORTAL_URL=$VITE_PORTAL_URL
ENV VITE_WIDGET_URL=$VITE_WIDGET_URL
ENV VITE_ANALYTICS_ID=$VITE_ANALYTICS_ID
ENV VITE_ENABLE_DEBUG=$VITE_ENABLE_DEBUG
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit
# Copy source code
COPY . .
# Build application
RUN npm run build
# ============================================
# Stage 2: Production
# ============================================
FROM nginx:alpine
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy built files
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create non-root user
RUN addgroup -g 1001 -S nginx-app && \
adduser -S nginx-app -u 1001 && \
chown -R nginx-app:nginx-app /usr/share/nginx/html
USER nginx-app
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
Best Practices
1. Never Commit Secrets
# Environment files with secrets
.env.local
.env.*.local
.env.production
# Keep templates
!.env.template
!.env.example
2. Validate Environment Variables
export function validateConfig() {
const required = [
'VITE_API_URL',
'VITE_PORTAL_URL',
];
const missing = required.filter(
key => !import.meta.env[key]
);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}\n` +
'Please check your .env file.'
);
}
}
// Call this in your main.tsx
validateConfig();
3. Use Sensible Defaults
export const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:5000',
apiTimeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'),
maxRetries: parseInt(import.meta.env.VITE_MAX_RETRIES || '3'),
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
};
4. Document Your Variables
Common Pitfalls and Solutions
Pitfall 1: Variables Not Available at Runtime
Solution: Ensure variables are set during Docker build:
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
Pitfall 2: Docker Cache Issues
Solution:
# Force rebuild without cache
docker compose build --no-cache frontend
# Or touch source files to invalidate cache
find src -type f -exec touch {} +
docker compose build frontend
Pitfall 3: Wrong Variable Names
Use TypeScript definitions to catch typos at compile time rather than runtime.
Pitfall 4: Hardcoded Values Remaining
Solution: Search your codebase for hardcoded URLs:
# Find hardcoded URLs
grep -r "https://api\." src/
grep -r "http://localhost" src/
Complete Example
Let's put it all together with a complete working example.
Project Structure
my-app/
βββ frontend/
β βββ src/
β β βββ config/
β β β βββ index.ts
β β β βββ validate.ts
β β βββ hooks/
β β β βββ useConfig.ts
β β βββ vite-env.d.ts
β β βββ main.tsx
β βββ .env.development
β βββ .env.production
β βββ .env.template
β βββ Dockerfile
β βββ package.json
β βββ vite.config.ts
βββ docker-compose.yml
βββ docker-compose.prod.yml
βββ .env.dev
βββ .env.prod
βββ .gitignore
Build and Deploy Scripts
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"build:prod": "vite build --mode production",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"docker:dev": "docker compose --env-file .env.dev up -d --build",
"docker:prod": "docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d --build",
"docker:down": "docker compose down"
}
}
Debugging Tips
1. Check Build-Time Variables
# Add to Dockerfile for debugging
RUN echo "VITE_API_URL: $VITE_API_URL" && \
echo "VITE_PORTAL_URL: $VITE_PORTAL_URL"
2. Inspect Container Environment
# Check environment variables in running container
docker exec my-container printenv | grep VITE
# Check built files
docker exec my-container cat /usr/share/nginx/html/assets/index-*.js | grep "api.example.com"
3. Enable Debug Logging
if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {
console.log('π§ Config:', {
apiUrl: import.meta.env.VITE_API_URL,
portalUrl: import.meta.env.VITE_PORTAL_URL,
mode: import.meta.env.MODE,
dev: import.meta.env.DEV,
prod: import.meta.env.PROD,
});
}
Conclusion
Managing environment variables across Vite, React, and Docker requires understanding how each tool handles configuration:
- Vite: Uses
VITE_prefix, loads from.envfiles - React: Accesses via
import.meta.envwith TypeScript support - Docker: Uses build args for build-time and env vars for runtime
- β
Always prefix client-side variables with
VITE_ - β Use TypeScript for type-safe environment variables
- β Separate development and production configurations
- β Pass variables to Docker via build args
- β
Use
--no-cachewhen environment variables change - β Validate required variables at startup
- β Never commit secrets to version control
- β Document all environment variables
By following these patterns and practices, you'll have a robust, maintainable configuration system that works seamlessly across all environments.
Additional Resources
- Vite Environment Variables Documentation
- Docker Compose Environment Variables
- TypeScript Declaration Files