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
.envfiles in code - Avoid loading fallback/multiple
.envfiles (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:
- Reads
infra/.env.{environment} - Generates
packages/*/.env.deploy-{environment}for each package - 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”.