Back to Blog
Architecture10 min readJanuary 1, 2024

How to Manage .env Files in a Monorepo

Monorepos introduce unique challenges for environment variable management. Learn strategies that scale with your codebase while keeping configuration secure.

The Monorepo Challenge

In a monorepo, you might have multiple applications (web app, API server, admin dashboard) and shared packages (UI components, utilities, database clients). Each may need different environment variables, but some variables should be shared across all packages.

monorepo/
apps/
web/ # Next.js frontend
api/ # Express backend
admin/ # Admin dashboard
packages/
database/ # Prisma client
ui/ # Shared components
config/ # Shared configuration

Strategy 1: Root-Level .env with Package Overrides

The simplest approach is a root .env file for shared variables, with package-specific .env files that add or override values:

monorepo/
.env # Shared vars
.env.local # Local secrets
apps/
web/.env # Web-specific
api/.env # API-specific

Root .env

# monorepo/.env
# Shared across all apps
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
LOG_LEVEL=info

App-Specific .env

# monorepo/apps/web/.env
NEXT_PUBLIC_API_URL=http://localhost:3001
PORT=3000
# monorepo/apps/api/.env
PORT=3001
JWT_SECRET=dev-secret

How to load: Tools like Turborepo can pass root env vars to tasks, or you can use a script that loads both files.

Strategy 2: Centralized Configuration Package

Create a shared configuration package that validates and exports environment variables with TypeScript types:

// packages/config/src/env.ts
import { z } from 'zod';
const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
});
const webSchema = baseSchema.extend({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_SITE_NAME: z.string().default('My App'),
});
const apiSchema = baseSchema.extend({
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3001),
});
export const webEnv = webSchema.parse(process.env);
export const apiEnv = apiSchema.parse(process.env);

Each app imports only the env object it needs:

// apps/web/src/lib/config.ts
import { webEnv } from '@myapp/config';
export const config = webEnv;

Benefits: Type safety, validation at startup, single source of truth for env var shapes.

Strategy 3: Turborepo Global Env

Turborepo has built-in support for global and package-specific environment variables:

// turbo.json
{
"globalEnv": [
"DATABASE_URL",
"REDIS_URL",
"NODE_ENV"
],
"pipeline": {
"web#build": {
"env": ["NEXT_PUBLIC_*"]
},
"api#build": {
"env": ["JWT_SECRET", "PORT"]
}
}
}

globalEnv: Variables available to all tasks (affects caching)
env:Package-specific variables that affect that task's cache

Dotenv-CLI Integration

Use dotenv-cli to load .env files before running commands:

// package.json
{
"scripts": {
"dev": "dotenv -e ../../.env -- turbo dev",
"build": "dotenv -e ../../.env -- turbo build"
}
}

Strategy 4: Environment Layers

For complex setups, use layered configuration with clear precedence:

# Load order (later files override earlier)
1. packages/config/.env.defaults # Safe defaults
2. .env # Shared config
3. apps/web/.env # App defaults
4. apps/web/.env.local # Local secrets
5. apps/web/.env.{NODE_ENV} # Environment-specific

Create a loader utility that handles this layering:

// packages/config/src/load-env.ts
import dotenv from 'dotenv';
import path from 'path';
export function loadEnv(appDir: string) {
const monorepoRoot = path.resolve(appDir, '../../');
// Load in order (earlier = lower priority)
const files = [
path.join(monorepoRoot, '.env'),
path.join(appDir, '.env'),
path.join(appDir, '.env.local'),
path.join(appDir, `.env.${process.env.NODE_ENV}`),
];
files.forEach(file => {
dotenv.config({ path: file, override: true });
});
}

Deployment Considerations

Vercel Monorepo Deployment

When deploying to Vercel, each app is deployed separately with its own environment variables:

  • Set shared variables at the project level in Vercel
  • Use different Vercel projects for different apps, or use the "Root Directory" setting
  • Environment variables set in Vercel override those in .env files

CI/CD Pipelines

In CI/CD, inject environment variables at the pipeline level:

# GitHub Actions example
jobs:
build:
env:
DATABASE_URL: ${secrets.DATABASE_URL}
steps:
- run: pnpm turbo build --filter=web
env:
NEXT_PUBLIC_API_URL: https://api.example.com

Best Practices Summary

1. Use .env.example files

Create .env.example in both root and each app to document all required variables.

2. Centralize validation

Use a shared config package with Zod or similar to validate env vars with types.

3. Keep secrets out of Git

Never commit .env.local or any file containing real credentials.

4. Document the loading order

Make it clear which files override which, and document this in your README.

5. Use platform env vars for production

Don't deploy .env files to production - use your hosting platform's secrets management.

Key Takeaways

  • Use root-level .env for shared configuration across packages
  • Package-specific .env files add or override shared values
  • A centralized config package provides type safety and validation
  • Turborepo's globalEnv and env options help manage caching correctly
  • Always document your env loading order for team clarity