Monorepo env vars: the pattern that actually scales across pnpm, Turborepo, and Nx
You have three packages. Each one needs its own env vars. Someone added a fourth last sprint and the .env.example hasn't been updated since. A new hire joined Monday and still can't boot the web app because nobody remembers which keys belong where — the answer is always "ask Ana on Slack."
By the end of this page you have a concrete pattern for env vars in a monorepo: one config at the repo root, per-package scoping, local overrides that don't fight your teammates', and task-runner integration for pnpm workspaces, Turborepo, and Nx. You'll also know where each piece of the pattern still breaks — a pattern that pretends to solve everything is a pattern you can't trust.
Why .env-per-package stops working in a monorepo
Three failure modes, in the order they hit a growing team.
1. Duplication drift
The same DATABASE_URL ends up in apps/api/.env, apps/worker/.env, and apps/admin/.env. Someone rotates the password in two of them. The third service crashes in staging the next morning because nobody remembered it also needed the new credential. The duplication isn't the bug — the bug is that there's no single place where a rotation can't be forgotten.
2. Onboarding tax
A new engineer clones the repo and can't start the frontend because .env.local is gitignored and the template is stale. They ask in Slack. Someone pastes a JWT in a DM. By the time they're productive, the secret has been pasted into two Slack channels and one screenshot. That's your security model.
3. Cache poisoning
Turborepo and Nx cache task outputs by hashing inputs, including env vars — but only the ones you declare in turbo.json's env field or Nx's inputs. Any var the task reads that isn't declared is invisible to the cache. The task runs once with a missing STRIPE_SECRET_KEY, the test passes because a mock kicked in, Turbo caches the success, and every subsequent CI run replays that broken state — until someone clears the cache and finds the bug is two sprints old.
The three patterns teams reach for, and why each one breaks
Before we name a pattern that works, a quick tour of the ones that don't. A reader who's already tried one of these needs to understand why it failed before a new one will land.
| Pattern | What it looks like | Why it breaks |
|---|---|---|
One root .env | A single file at the repo root. Every package reads it with dotenv-cli or a wrapper script. | Leaks unrelated secrets into every workspace — your frontend bundle now has the database password in scope. CI can't enforce least privilege when the whole file is always mounted. |
Symlinks to root | apps/api/.env → ../../.env committed to the repo so every package sees the same file. | Works on macOS and Linux. Breaks on Windows devs, on fresh CI checkouts that don't preserve symlinks, and on Docker build contexts that copy apps/api/ in isolation. You find out during a vendor onboarding, at the worst possible time. |
env-cmd / dotenv-cli wrappers | Every package.json script gets prefixed with env-cmd -f ../../.env.local or similar. | Still a .env per package — you've just moved the file path around. Nothing is shared, nothing is scoped, the scripts get noisier, and the wrapper adds a new failure mode when the file isn't where the wrapper expects. |
.env.example + copy script | Template committed, real file gitignored, onboarding doc says "cp .env.example .env and fill in the values." | The template drifts the day a new var ships. Three sprints later it's missing four keys and nobody knows which. The new hire copies an old template and spends their second day debugging undefined is not a function. |
The shape of the failure is always the same: a pattern that holds for 3 packages and 5 env vars falls over at 8 packages and 30 env vars, and nobody can point to the moment it stopped working.
What the right answer looks like (tool-agnostic)
Before we name a product, here's the shape of a solution that scales. If you don't have something like this in your monorepo, it will stop working for the same reasons the patterns above did — whether you build it yourself or adopt a tool.
1. One source of truth at the repo root.
A single file declares which packages exist, which env vars each one needs, and which environment (development, staging, production) they default to. Rotating a secret is one update; it propagates to every package that declared the key.
2. Per-package scoping.
Package A gets its keys. Package B can't see them. The frontend bundle can't accidentally include the Stripe secret because the frontend workspace never pulls it.
3. Per-user local overrides.
Your .env.local and your teammate's .env.local stay independent. You can point your local API at a staging URL without committing that choice into the shared config and breaking their build.
4. Task-runner awareness.
Whatever orchestrates your builds (Turborepo, Nx, or a Makefile) knows which env vars each task reads, so cache keys include them. An undeclared env var is a build error, not a silent cache hit.
An answer that gives you three of these four leaves one drift vector in place. You want all four.
How Envshed implements that pattern
Envshed is a hosted secrets manager with a CLI that reads a single config file at your repo root. The config maps workspaces to projects in your Envshed organization. envshed pull walks the config, fetches the keys each workspace declared, and writes them to disk where your code already expects to find them.
Here's the real .envshed.json schema (package → { project, defaultEnv?, file?, org? }):
{
"org": "acme",
"project": "platform",
"defaultEnv": "development",
"workspaces": {
"apps/api": { "project": "platform-api" },
"apps/web": { "project": "platform-web", "defaultEnv": "development" },
"packages/auth": { "project": "platform-auth", "file": ".env.local" }
}
}Each workspace entry points to its own Envshed project. One envshed pull at the root hits every workspace in parallel. envshed pull --workspace apps/api scopes the pull to a single package — useful in CI jobs that only touch one service.
The file field overrides the default output path (./.env relative to the workspace). Use .env.local for the workspaces where Next.js or Vite expects it.
Secret values are encrypted at rest with AES-256-GCM on the Envshed side; envshed pull decrypts them in-flight and writes them to the paths above. Nothing gets logged server-side, and nothing about your config leaves the repo.
Walkthrough: pnpm workspaces
pnpm's pnpm-workspace.yaml declares package globs. envshed init reads that file directly when you run it at the repo root.
Step 1 — Detect and pick packages.
envshed init reads pnpm-workspace.yaml and shows every package it finds under the configured globs. Pick the ones you want to track — the default is all of them.
Step 2 — Map each package to an Envshed project.
For each selected package, pick or create an Envshed project and an environment. The CLI writes the mapping into .envshed.json at the repo root.
Step 3 — Commit the config.
.envshed.json has no secret values — only slugs and file paths — so it's safe to commit. That commit is also your onboarding doc: a new hire clones the repo, runs envshed pull, and the full monorepo boots.
From there, pulling secrets is one command:
envshed pull # pulls every workspace in parallel envshed pull --workspace apps/api # scopes the pull to one package envshed pull --env staging # same config, staging values
Wire it into your dev script so every local start picks up the latest values:
{
"scripts": {
"dev": "envshed pull && turbo run dev"
}
}The pull takes a few hundred milliseconds cold, so nobody's pnpm dev feels slower. CLI reference: envshed init, envshed workspace, envshed pull.
Walkthrough: Turborepo
Turborepo's superpower is task caching. Its blind spot — the one that ships broken env vars to prod — is that it only hashes the env vars you declare in turbo.json. Here's the wiring that makes Envshed and Turbo agree with each other.
Step 1 — Make the pull a task dependency.
Declare an envshed:pull task and pull it in via dependsOn. Set cache: false on both the pull and any long-running persistent task — the pull has to run every time to pick up rotated values, and persistent tasks are never cacheable anyway.
Step 2 — Declare every env var in Turbo's env.
Anything your build reads via process.env that isn't listed here is invisible to Turbo's cache hashing. Undeclared env vars are the #1 cause of "works on my machine, cached-broken on CI." Keep this list honest.
{
"tasks": {
"envshed:pull": {
"cache": false
},
"dev": {
"dependsOn": ["envshed:pull"],
"persistent": true,
"cache": false
},
"build": {
"dependsOn": ["envshed:pull", "^build"],
"env": ["DATABASE_URL", "STRIPE_SECRET_KEY", "NEXTAUTH_SECRET"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}Step 3 — Wire it into CI with a service token.
In CI, issue a scoped Envshed service token per environment, set it as a CI secret, and call envshed pull at the start of the job. The token is the only Envshed-specific value your CI config needs — everything else rotates in Envshed.
- run: envshed pull
env:
ENVSHED_TOKEN: ${{ secrets.ENVSHED_CI_TOKEN }}Walkthrough: Nx
Nx models every app and library as a separate project with its own project.json. The .envshed.json workspaces map lines up one-to-one: one Envshed workspace entry per Nx project.
Step 1 — Let each project read its own .env.
Nx automatically picks up apps/<name>/.env when executing a target in that project's cwd, so envshed pull writing to that path is all you need.
Step 2 — Make the pull an Nx task dependency.
Add a local run-commands target per project and chain it with dependsOn on the targets that need secrets:
{
"name": "api",
"targets": {
"envshed": {
"executor": "nx:run-commands",
"options": {
"command": "envshed pull --workspace apps/api",
"cwd": "{workspaceRoot}"
}
},
"serve": {
"dependsOn": ["envshed"],
"executor": "@nx/node:node"
}
}
}Step 3 — Declare env vars in namedInputs.
Nx only hashes env vars you declare — same rule as Turbo. Use namedInputs so the env set is reusable across targets:
{
"namedInputs": {
"runtime": [
{ "env": "DATABASE_URL" },
{ "env": "REDIS_URL" }
]
},
"targetDefaults": {
"build": { "inputs": ["default", "runtime"] }
}
}The parts we don't pretend to solve
Because you'll notice if we don't say it.
We don't run a sidecar or a proxy.
Envshed writes values to .env files on disk. Once the file is written, your code reads it the normal way — which means anyone on the machine can read it. If you need a process-memory-only secret boundary, Envshed isn't the answer for that specific requirement.
We don't replace CI-level secret stores for production deploys.
Vercel, AWS Parameter Store, 1Password Secrets Automation, GitHub Actions Environments — those tools own production deploys in most shops, and they should. Envshed is the day-to-day developer experience: the local pull, the onboarding bootstrap, the staging environment a product manager needs on their laptop. It integrates with production secret stores — see the GitHub Actions guide — it doesn't replace them.
We don't solve schema drift at the language level.
If your code reads process.env.DATABSE_URL with a typo, Envshed can't catch that. Use @t3-oss/env-core, Zod, or hand-rolled validators at each package's entrypoint. Envshed tells you which keys exist; your runtime tells you whether they're the keys you expected.
These limits are load-bearing. A secrets manager that claims to solve everything is either a proxy you have to operate or a lock-in you can't leave. Envshed is neither.
FAQ
Should .env files be committed in a monorepo?
No, and the answer doesn't change between single-repo and monorepo. .env files contain secret values for a specific environment; they belong in .gitignore. Your .envshed.json is what you commit — it holds only slugs and file paths, no values. If you're using the .env.example pattern today, keep it as a reference for required keys; Envshed doesn't replace that file.
How do I share a single env var like DATABASE_URL across packages without duplicating it?
Point both workspaces at the same Envshed project. apps/api and apps/worker both declare { "project": "platform-api" } in .envshed.json's workspaces map. One DATABASE_URL lives in one Envshed project and gets pulled into both workspaces on envshed pull. Rotate it once; both packages pick it up on the next pull.
What about Next.js picking up .env.local automatically?
Supported. Set "file": ".env.local" in that workspace's config and envshed pull writes to the path Next.js expects. Per-user overrides that stay out of the shared config can live next to it as a second local file — Next.js merges them with its documented precedence rules.
How do I run the whole repo with staging env in one command?
envshed pull --env staging && pnpm dev. The --env flag overrides the default environment for every workspace in one shot. Useful for product reviews, bug reproduction, and the occasional "what does prod data look like against this feature branch" investigation.
Is this compatible with Docker Compose?
Yes. Run envshed pull before docker compose up — Compose reads .env files from the build context like everything else. The CI-time alternative is an Envshed service token in the Compose runtime; the CLI reference covers the token flow.
How is this different from Doppler, 1Password Secrets Automation, or Vault?
Those are general-purpose secrets managers; Envshed is monorepo-native. The config-at-the-root pattern, per-workspace pulls, and --workspace scoping are first-class behaviors, not workflows you script around a general-purpose tool. At 2–50 dev scale, the day-to-day UX is the whole product. If you need policy-as-code and multi-region HSMs, you've outgrown Envshed.
See the pattern running
Rather than copy snippets from this page, clone the demo monorepo. It's three packages — a Fastify API, a Next.js 15 web app, and a shared auth library — wired up with pnpm-workspace.yaml, turbo.json, and an .envshed.json using the real schema from this guide. From git clone to a running dev server in under five minutes.
Public, MIT-licensed. Fastify + Next.js 15 + pnpm workspaces + Turborepo, with turbo.json configured so env-file changes actually bust the cache.
Related reading
- Monorepo env vars — one config for every package — the short version, for readers who've already decided.
- Dotenv alternative — when .env files stop being enough, regardless of repo shape.
- Env vars in GitHub Actions — the CI-time pattern that complements this one.
- Envshed vs. Doppler, vs. 1Password Secrets, vs. Vault — where each tool fits.
Drop the .env-per-package juggling
One command at the root. Every workspace pulls exactly what it needs. $5 per user, flat.
Start freeA pillar guide from Envshed.