Managing Environment Files in Monorepos


Monorepos with multiple apps and services create environment configuration challenges. Each package needs its own .env, you need to test locally against remote environments, and deployments require different settings than development.

The Problems

Multiple Packages

Each app/service in your monorepo needs environment configuration:

packages/
  api/          # Backend service
  web/          # Frontend app
  worker/       # Background jobs
  cli/          # Command-line tool

Each has different environment variables, but all need consistent handling.

Deployment vs Local-Remote Testing

Two distinct use cases that get conflated:

Local development pointing to remote: Developer runs yarn dev but connects to deployed dev database/APIs for testing.

Deployment builds: CI/CD builds production-ready code with production configuration.

Traditional .env setups confuse these by using the same files for both.

Principles

File Layout

Package-level files for local development:

packages/api/.env.local      # API connecting to localhost database
packages/api/.env.dev        # API connecting to deployed dev database
packages/api/.env.staging    # API connecting to staging
packages/api/.env.prod       # API connecting to production (read-only!)

packages/web/.env.local      # Web app pointing to localhost API
packages/web/.env.dev        # Web app pointing to dev API

Infrastructure-level files (source of truth for deployments):

infra/.env.dev
infra/.env.staging
infra/.env.prod

Deployment-generated files (never committed):

packages/api/.env.deploy-dev
packages/api/.env.deploy-staging
packages/api/.env.deploy-prod

Accept Different .env Files

Every service and command must accept explicit environment configuration:

# Backend API
yarn workspace api dev              # Uses .env.local
yarn workspace api dev:dev          # Uses .env.dev
yarn workspace api seed --mode dev  # Uses .env.dev

# Frontend
yarn workspace web dev              # Uses .env.local
yarn workspace web dev:staging      # Uses .env.staging

No Default Logic

Remove code that assumes or defaults to a specific .env file.

Bad:

const envFile = process.env.NODE_ENV === 'production'
  ? '.env.prod'
  : '.env.dev'  // Hidden default!

Good:

const mode = process.env.MODE
if (!mode) {
  throw new Error('MODE environment variable required')
}
const envFile = `.env.${mode}`
if (!fs.existsSync(envFile)) {
  throw new Error(`Missing required file: ${envFile}`)
}

Clear Naming for Commands

Use consistent naming across all packages:

{
  "scripts": {
    "dev": "...",              // Default to local
    "dev:local": "...",        // Explicit local
    "dev:dev": "...",          // Connect to deployed dev
    "dev:staging": "...",      // Connect to staging
    "dev:prod": "...",         // Connect to prod (read-only)
    "deploy:dev": "...",       // Deploy to dev
    "deploy:staging": "...",   // Deploy to staging
    "deploy:prod": "..."       // Deploy to production
  }
}

Implementation: Vite

Vite’s default behavior loads multiple .env files with fallback chains. Override this to load exactly one file.

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import fs from 'fs'

export default defineConfig(({ mode }) => {
  // Map 'development' to 'local'
  const envMode = mode === 'development' ? 'local' : mode
  const envFile = `.env.${envMode}`

  // Fail fast if file doesn't exist
  if (!fs.existsSync(envFile)) {
    throw new Error(`Missing required environment file: ${envFile}`)
  }

  // Load ONLY the specific file - no fallbacks
  const env = loadEnv(envMode, process.cwd(), '')

  return {
    envPrefix: [],  // Disable Vite's automatic multi-file loading
    define: {
      'import.meta.env': env,
    },
  }
})

Package scripts:

{
  "scripts": {
    "dev": "vite",                      // Loads .env.local
    "dev:local": "vite --mode local",
    "dev:dev": "vite --mode dev",       // Loads .env.dev
    "dev:staging": "vite --mode staging",
    "dev:prod": "vite --mode prod"
  }
}

Deployment Flow

When deploying, the infrastructure deployment script:

  1. Reads infra/.env.{environment}
  2. Generates packages/*/.env.deploy-{environment} for each package
  3. Runs vite build --mode deploy-{environment} which loads the generated file

This separates “build for production” from “production environment configuration”.

See Environment Configuration Best Practices for principles on avoiding defaults, failing fast, and explicit values.