πŸ‡ΊπŸ‡¦ Stand with Ukraine - Support Ukrainian Heroes

Managing Environment Variables in Vite, Docker, and React Applications

πŸ’‘ What You'll Learn: This comprehensive guide covers environment variable management across Vite, React, and Docker. You'll learn about the VITE_ prefix requirement, TypeScript integration, multi-stage builds, and production deployment strategies with complete working examples.

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

  1. Understanding Environment Variables
  2. Vite Environment Variables
  3. React and TypeScript Integration
  4. Docker Environment Variables
  5. Multi-Stage Docker Builds
  6. Best Practices
  7. Common Pitfalls and Solutions
  8. 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

⚠️ Critical Rule: Only variables prefixed with VITE_ are exposed to your client-side code.
.env
# ❌ 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

.env.development
# 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=
.env.production
# 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:

src/vite-env.d.ts
/// <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

src/hooks/useConfig.ts
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

src/components/MyComponent.tsx
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

docker-compose.yml
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"
.env.prod
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

Important Distinction:
  • Build Args (ARG): Used during image build, baked into the image
  • Environment Variables (ENV): Available at runtime, can be overridden
Dockerfile
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

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

.gitignore
# Environment files with secrets
.env.local
.env.*.local
.env.production

# Keep templates
!.env.template
!.env.example

2. Validate Environment Variables

src/config/validate.ts
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

βœ… Best Practice: Create a comprehensive documentation file listing all environment variables, their purposes, and example values. This helps team members and future maintainers understand the configuration requirements.

Common Pitfalls and Solutions

Pitfall 1: Variables Not Available at Runtime

Problem: Environment variables are undefined in the production build.

Solution: Ensure variables are set during Docker build:

ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

Pitfall 2: Docker Cache Issues

Problem: Changes to environment variables don't reflect after rebuild.

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

package.json
{
  "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 .env files
  • React: Accesses via import.meta.env with TypeScript support
  • Docker: Uses build args for build-time and env vars for runtime
Key Takeaways:
  • βœ… 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-cache when 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


πŸ’¬ Have Questions or Suggestions? Use the comments section below to share your thoughts, ask questions, or share your own environment variable management strategies!