Back to Blog
Architecture10 min readMay 2, 2026

How to Manage .env Files in a Monorepo

Add a second app to a monorepo and your single .env stops being enough. Add a third and the question of what's shared versus what belongs to one package only gets harder to answer by memory.

The Monorepo Challenge

A typical layout has a few applications (a web app, an API server, an admin dashboard) alongside shared packages (UI components, a database client, utilities). The API needs a JWT secret the web app has no business seeing. Both need the same database URL. Neither should have to guess where that URL is supposed to come from.

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

Root .env, Package Overrides

The lowest-effort option: one .env at the repo root for anything shared, plus a .env per package for whatever that package alone needs:

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

Turborepo can pass root env vars into tasks directly, or you write a small script that loads both files before anything else runs. Either way, this scales fine until you have enough variables that nobody remembers which file a given one lives in, which for most teams is sooner than expected.

A Centralized Config Package

Once memorizing file locations stops working, move validation into code. A shared package that parses process.env with Zod gives every app the same guarantees and catches a missing variable at startup instead of three requests in:

// 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;

You get type safety, one place that knows the shape of every app's config, and a build that fails immediately instead of a runtime error in production three weeks later.

Turborepo's Global Env Settings

Turborepo tracks which env vars affect its build cache, and it needs to be told about both the ones shared everywhere and the ones scoped to one package's tasks:

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

globalEnv applies to every task and its value change busts every cache. env is scoped to one task, so changing JWT_SECRET only invalidates the API build, not the web one too.

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"
}
}

Layering for Larger Setups

A monorepo with a dozen packages usually needs more than two tiers. Define a clear precedence up front so nobody has to reverse-engineer it from a bug report:

# 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

A small loader utility can apply that order consistently across every app instead of leaving each one to reimplement it slightly differently:

// 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

Deploying to Vercel

Vercel deploys each app in the monorepo as its own project, which means its own set of environment variables too. Set shared values at the project level, point each app at its own project via the "Root Directory" setting, and remember that whatever you set in the Vercel dashboard overrides anything sitting in a .env file.

CI/CD Pipelines

In CI, inject variables at the pipeline level rather than relying on a checked-in file:

# 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

What Matters Once You're at This Scale

.env.example at every level

Root and per-app. A new hire cloning the repo shouldn't have to ask in Slack what variables api/ needs.

Validation lives in one package

A shared Zod schema catches a missing or malformed variable at startup, before it becomes a 3am page.

Secrets stay out of Git

.env.local and anything with a real credential in it never gets committed, at any level of the repo.

The loading order is written down somewhere

Put it in the README. The person debugging why a value won't override at 11pm is often you, six months from now.

Pick whichever combination of these fits the size of the repo you have. A two-package monorepo doesn't need a centralized config package with Zod schemas any more than a fifteen-package one can get by on a single root .env. What causes real pain later is skipping the decision entirely and letting each app improvise its own approach.