Managing Environment Files in Monorepos


Monorepos with multiple apps and deployment environments can become cumbersome over time. The tips below might seem overkill at first, but at scale, they should make the process clear and manageable.

This article builds upon the ideas presented in Environment Configuration Best Practices, namely the principles of avoiding defaults, failing fast, and explicit values.

Separate out your .env files

I personally like to have separate .env files for:

  • each environment
  • each app
  • running vs deploying

Put together this results in the following folder structure:

├── packages
│   ├── api
│   │   ├── .env.example
│   │   ├── .env.local        # API connecting to localhost database
│   │   ├── .env.dev          # API connecting to deployed dev database
│   │   └── .env.prod         # API connecting to production
│   └── web
│       ├── .env.example
│       ├── .env.local        # Web app pointing to localhost API
│       └── .env.dev          # Web app pointing to dev API
└── infra
    ├── .env.example
    ├── .env.dev
    └── .env.prod

Accept different .env files

Ensure every service, app and command accepts an explicit environment name or path. This might need to be explicitly added and should support values like deploy-dev (see the next section). Furthermore:

  • Avoid defaulting to .env files in code
  • Avoid loading fallback/multiple .env files (see below about Vite)

Clear commands

Once the different files are established, I like to create a very clear set of environment specific commands in my codebase:

{
  "scripts": {
    "watch": "...", // Default to local
    "watch:local": "...", // Explicit local
    "watch:dev": "...", // Connect to deployed dev
    "watch:prod": "...", // Connect to prod (read-only)
    "deploy:dev": "...", // Deploy to dev
    "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,
    },
  };
});

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 will result in the creation of the files:

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

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