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.
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:
Root .env
App-Specific .env
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:
Each app imports only the env object it needs:
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:
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:
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:
A small loader utility can apply that order consistently across every app instead of leaving each one to reimplement it slightly differently:
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:
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.