Last updated: June 2026


Introduction: When .env Files Become a Liability

I still remember the 3 a.m. Slack message: “The staging API is hitting the production database.” We had three Next.js apps in a growing monorepo, each with its own .env.local file, maintained manually by different developers. Someone had copy-pasted the wrong DATABASE_URL. That incident cost us a weekend and taught me that managing environment variables in a Next.js Turborepo monorepo is not a detail — it’s an architecture decision.

If you’re running multiple Next.js apps in a Turborepo monorepo, scattered .env files are a time bomb. In this guide, I’ll walk you through the system I now use in production: shared base configs, per-app overrides, strict CI validation, and zero accidental leaks.


TL;DR

  • Store shared environment variables in a root-level packages/env package and import them into each app with full TypeScript validation using @t3-oss/env-nextjs.
  • Use Turborepo’s globalEnv and per-task env fields in turbo.json to declare which variables each pipeline task depends on — this ensures correct cache invalidation.
  • Never commit .env files with real secrets; use a secrets manager (Doppler, Infisical, or AWS Secrets Manager) to inject variables at build and runtime.

Why Environment Variable Management Breaks at Monorepo Scale

In a Next.js monorepo with Turborepo, environment variables affect two critical things beyond just runtime config. First, they affect Turborepo’s build cache — if Turbo doesn’t know a variable changed, it will incorrectly serve a cached build. Second, Next.js’s NEXT_PUBLIC_ prefix controls whether a variable is bundled into client-side JavaScript — a mistake here means leaking server secrets to the browser.

Both failure modes are silent. You won’t get an error; you’ll get either a stale build or a security incident.

The gap between “it works” and “it works correctly” in env var management is exactly where production incidents hide.


Prerequisites

  • Turborepo 2.x (npx create-turbo@latest or existing monorepo)
  • Next.js 14+ (App Router or Pages Router — this guide covers both)
  • At least two apps in apps/ (e.g., apps/web and apps/admin)
  • Node.js 20+, pnpm 9+ (the examples use pnpm workspaces, but npm/yarn work too)

Step-by-Step Implementation

Step 1: Understand Turborepo’s Env Variable Model

Turborepo has two ways to declare environment variable dependencies in turbo.json:

  • globalEnv: Variables that apply to every task in the pipeline. A change to any of these busts the entire cache.
  • env (per task): Variables that only affect a specific task. Changing DATABASE_URL only busts the build task cache, not the lint task.

Here’s a baseline turbo.json that reflects this:

json

{
  "$schema": "https://turbo.build/schema.json",
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "env": [
        "NEXT_PUBLIC_APP_URL",
        "NEXT_PUBLIC_SUPABASE_URL",
        "NEXT_PUBLIC_SUPABASE_ANON_KEY",
        "DATABASE_URL"
      ]
    },
    "test": {
      "env": ["DATABASE_URL", "TEST_SECRET"]
    },
    "lint": {
      "outputs": []
    }
  }
}

Important: Any environment variable used at build time that is not listed under env will be silently ignored by Turbo’s cache hash. Your app will run fine — but Turbo might serve a stale cached build when the variable changes. I discovered this after a 2-hour debugging session in CI.

Step 2: Create a Shared packages/env Package

Instead of duplicating validation logic across each app, I maintain a single shared package that exports validated, type-safe environment objects.

First, install the validation library:

bash

pnpm add @t3-oss/env-nextjs zod --filter @repo/env

Create the package:

packages/
  env/
    package.json
    index.ts

json

// packages/env/package.json
{
  "name": "@repo/env",
  "version": "0.0.1",
  "main": "./index.ts",
  "exports": {
    ".": "./index.ts"
  },
  "dependencies": {
    "@t3-oss/env-nextjs": "^0.10.0",
    "zod": "^3.22.0"
  }
}

ts

// packages/env/index.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    INTERNAL_API_SECRET: z.string().min(32),
    NODE_ENV: z.enum(["development", "test", "production"]),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
    NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(10),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET,
    NODE_ENV: process.env.NODE_ENV,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
    NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  },
});

Now in any app, instead of accessing process.env.DATABASE_URL directly, I import from @repo/env:

ts

// apps/web/lib/db.ts
import { env } from "@repo/env";

const client = new PrismaClient({
  datasources: { db: { url: env.DATABASE_URL } },
});

If DATABASE_URL is missing or malformed, the app throws at startup with a clear Zod validation error — not a cryptic Cannot read properties of undefined somewhere deep in a query.

Step 3: Structure .env Files Across the Monorepo

I use a layered approach:

.env.base          # Non-secret, shared defaults (committed to git)
.env               # Root-level secrets (gitignored, injected by CI)
apps/
  web/
    .env.local     # App-specific local overrides (gitignored)
  admin/
    .env.local     # App-specific local overrides (gitignored)

bash

# .env.base (committed — safe defaults only, no secrets)
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000

bash

# .gitignore (root)
.env
.env.local
apps/**/.env.local
packages/**/.env.local

Pro Tip: Use dotenv-cli to load multiple env files in sequence. Running dotenv -e .env.base -e .env -- turbo build lets CI inject a minimal .env with just the secrets, while .env.base provides the safe defaults. This pattern prevents “it works on my machine” surprises.

bash

npm install -D dotenv-cli

json

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

Step 4: Validate Environment at Startup, Not at Runtime

The worst time to discover a missing env var is when a user hits a production API endpoint. I add an explicit validation step to each app’s next.config.ts:

ts

// apps/web/next.config.ts
import { env } from "@repo/env"; // This throws on missing/invalid vars

const nextConfig = {
  // your config here
};

export default nextConfig;

Because @t3-oss/env-nextjs calls createEnv at module load time, importing it in next.config.ts ensures validation runs before Next.js starts. A bad deploy fails fast at the startup phase, not in production traffic.

Step 5: Handle Per-App Variable Overrides

Some variables differ between apps — for example, NEXT_PUBLIC_APP_URL is different for apps/web and apps/admin. The packages/env shared package can’t know these at authoring time.

My solution: export a factory function instead of a singleton.

ts

// packages/env/index.ts (factory version)
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export function createAppEnv(appEnv: { NEXT_PUBLIC_APP_URL: string }) {
  return createEnv({
    server: {
      DATABASE_URL: z.string().url(),
      NODE_ENV: z.enum(["development", "test", "production"]),
    },
    client: {
      NEXT_PUBLIC_APP_URL: z.string().url(),
    },
    runtimeEnv: {
      DATABASE_URL: process.env.DATABASE_URL,
      NODE_ENV: process.env.NODE_ENV,
      NEXT_PUBLIC_APP_URL: appEnv.NEXT_PUBLIC_APP_URL,
    },
  });
}

ts

// apps/admin/env.ts
import { createAppEnv } from "@repo/env";

export const env = createAppEnv({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_ADMIN_URL!,
});

This keeps shared validation centralized while allowing each app to supply its own variable binding.


How Do You Safely Manage Secrets in a Turborepo CI/CD Pipeline?

With the structure in place, the remaining risk is secret injection. Here’s how I handle it in production.

Use Doppler or Infisical for secret injection. I sync secrets into Vercel, GitHub Actions, and local dev from a single source of truth. Running doppler run -- turbo build injects all secrets into the process environment before Turbo runs.

Separate build-time from runtime variables. Variables used in generateStaticParams or at build time must be available during turbo build. Variables only needed at request time (like session secrets) don’t need to be in env in turbo.json, but they do need to be in your deployment environment.

Audit NEXT_PUBLIC_ variables before every deploy. In CI, I run a quick check:

bash

# Print all NEXT_PUBLIC_ vars being used (audit before deploy)
grep -r "NEXT_PUBLIC_" apps/ --include="*.ts" --include="*.tsx" | \
  grep -oP 'NEXT_PUBLIC_\w+' | sort -u

Compare this list against what’s actually declared in turbo.json. Any mismatch is a potential cache bug.


Common Errors and How I Fixed Them

Error: Turborepo cache not invalidating when env var changes
Cause: The variable wasn’t listed in turbo.json‘s env array for that task. Add it explicitly — Turbo does not auto-detect env var usage.

Error: process.env.X is undefined in a Client Component
Cause: The variable is missing the NEXT_PUBLIC_ prefix. Server-side variables are stripped from the client bundle. Rename it and update turbo.json.

Error: @t3-oss/env-nextjs throws Invalid environment variables with no details
Fix: Add .catch((err) => { console.error(err.issues); process.exit(1); }) or check the ZodError output. The library prints a formatted error table, but only if your terminal renders it — in some CI environments you need to check stderr explicitly.

Error: dotenv-cli loads variables but Turborepo still misses them
Cause: Turborepo reads env variables from the process at invocation time, after dotenv loads them. Make sure dotenv-cli wraps the entire turbo command, not just the script before it.


FAQ

Q: How do I share environment variables across multiple Next.js apps in a Turborepo monorepo?
A: Create a packages/env package with @t3-oss/env-nextjs that exports validated env objects. Each app imports from this shared package, ensuring consistent types and validation logic across the entire monorepo.

Q: Why does Turborepo serve a stale build when I change an environment variable?
A: Turborepo only invalidates its cache for variables explicitly listed in the env array of each task in turbo.json. If the variable is missing from that list, Turbo won’t include it in the cache hash and will ignore the change.

Q: How should I handle secret environment variables in a Next.js Turborepo CI/CD pipeline?
A: Never commit secrets to .env files in your repo. Use a secrets manager like Doppler, Infisical, or your cloud provider’s secret store. In GitHub Actions, inject secrets via environment variables at the workflow level and reference them in the turbo build command.

Q: What is the difference between globalEnv and per-task env in turbo.json?
A: globalEnv affects all tasks — a change busts the entire cache. Per-task env only busts the cache for that specific task. Use globalEnv for variables like NODE_ENV and CI, and per-task env for app-specific variables like DATABASE_URL.

Q: How do I prevent Next.js from accidentally leaking server environment variables to the browser in a monorepo?
A: Use @t3-oss/env-nextjs, which separates server and client schemas. Client-side variables must start with NEXT_PUBLIC_. If you try to access a server variable in a Client Component, you’ll get a build-time error rather than a silent undefined.


Conclusion

Env Variables in Next.js Monorepos: The Right Way with Turborepo

Last updated: June 2026


Introduction: When .env Files Become a Liability

I still remember the 3 a.m. Slack message: “The staging API is hitting the production database.” We had three Next.js apps in a growing monorepo, each with its own .env.local file, maintained manually by different developers. Someone had copy-pasted the wrong DATABASE_URL. That incident cost us a weekend and taught me that managing environment variables in a Next.js Turborepo monorepo is not a detail — it’s an architecture decision.

If you’re running multiple Next.js apps in a Turborepo monorepo, scattered .env files are a time bomb. In this guide, I’ll walk you through the system I now use in production: shared base configs, per-app overrides, strict CI validation, and zero accidental leaks.


TL;DR

  • Store shared environment variables in a root-level packages/env package and import them into each app with full TypeScript validation using @t3-oss/env-nextjs.
  • Use Turborepo’s globalEnv and per-task env fields in turbo.json to declare which variables each pipeline task depends on — this ensures correct cache invalidation.
  • Never commit .env files with real secrets; use a secrets manager (Doppler, Infisical, or AWS Secrets Manager) to inject variables at build and runtime.

Why Environment Variable Management Breaks at Monorepo Scale

In a Next.js monorepo with Turborepo, environment variables affect two critical things beyond just runtime config. First, they affect Turborepo’s build cache — if Turbo doesn’t know a variable changed, it will incorrectly serve a cached build. Second, Next.js’s NEXT_PUBLIC_ prefix controls whether a variable is bundled into client-side JavaScript — a mistake here means leaking server secrets to the browser.

Both failure modes are silent. You won’t get an error; you’ll get either a stale build or a security incident.

The gap between “it works” and “it works correctly” in env var management is exactly where production incidents hide.


Prerequisites

  • Turborepo 2.x (npx create-turbo@latest or existing monorepo)
  • Next.js 14+ (App Router or Pages Router — this guide covers both)
  • At least two apps in apps/ (e.g., apps/web and apps/admin)
  • Node.js 20+, pnpm 9+ (the examples use pnpm workspaces, but npm/yarn work too)

Step-by-Step Implementation

Step 1: Understand Turborepo’s Env Variable Model

Turborepo has two ways to declare environment variable dependencies in turbo.json:

  • globalEnv: Variables that apply to every task in the pipeline. A change to any of these busts the entire cache.
  • env (per task): Variables that only affect a specific task. Changing DATABASE_URL only busts the build task cache, not the lint task.

Here’s a baseline turbo.json that reflects this:

json

{
  "$schema": "https://turbo.build/schema.json",
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "env": [
        "NEXT_PUBLIC_APP_URL",
        "NEXT_PUBLIC_SUPABASE_URL",
        "NEXT_PUBLIC_SUPABASE_ANON_KEY",
        "DATABASE_URL"
      ]
    },
    "test": {
      "env": ["DATABASE_URL", "TEST_SECRET"]
    },
    "lint": {
      "outputs": []
    }
  }
}

Important: Any environment variable used at build time that is not listed under env will be silently ignored by Turbo’s cache hash. Your app will run fine — but Turbo might serve a stale cached build when the variable changes. I discovered this after a 2-hour debugging session in CI.

Step 2: Create a Shared packages/env Package

Instead of duplicating validation logic across each app, I maintain a single shared package that exports validated, type-safe environment objects.

First, install the validation library:

bash

pnpm add @t3-oss/env-nextjs zod --filter @repo/env

Create the package:

packages/
  env/
    package.json
    index.ts

json

// packages/env/package.json
{
  "name": "@repo/env",
  "version": "0.0.1",
  "main": "./index.ts",
  "exports": {
    ".": "./index.ts"
  },
  "dependencies": {
    "@t3-oss/env-nextjs": "^0.10.0",
    "zod": "^3.22.0"
  }
}

ts

// packages/env/index.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    INTERNAL_API_SECRET: z.string().min(32),
    NODE_ENV: z.enum(["development", "test", "production"]),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
    NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(10),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET,
    NODE_ENV: process.env.NODE_ENV,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
    NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  },
});

Now in any app, instead of accessing process.env.DATABASE_URL directly, I import from @repo/env:

ts

// apps/web/lib/db.ts
import { env } from "@repo/env";

const client = new PrismaClient({
  datasources: { db: { url: env.DATABASE_URL } },
});

If DATABASE_URL is missing or malformed, the app throws at startup with a clear Zod validation error — not a cryptic Cannot read properties of undefined somewhere deep in a query.

Step 3: Structure .env Files Across the Monorepo

I use a layered approach:

.env.base          # Non-secret, shared defaults (committed to git)
.env               # Root-level secrets (gitignored, injected by CI)
apps/
  web/
    .env.local     # App-specific local overrides (gitignored)
  admin/
    .env.local     # App-specific local overrides (gitignored)

bash

# .env.base (committed — safe defaults only, no secrets)
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000

bash

# .gitignore (root)
.env
.env.local
apps/**/.env.local
packages/**/.env.local

Pro Tip: Use dotenv-cli to load multiple env files in sequence. Running dotenv -e .env.base -e .env -- turbo build lets CI inject a minimal .env with just the secrets, while .env.base provides the safe defaults. This pattern prevents “it works on my machine” surprises.

bash

npm install -D dotenv-cli

json

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

Step 4: Validate Environment at Startup, Not at Runtime

The worst time to discover a missing env var is when a user hits a production API endpoint. I add an explicit validation step to each app’s next.config.ts:

ts

// apps/web/next.config.ts
import { env } from "@repo/env"; // This throws on missing/invalid vars

const nextConfig = {
  // your config here
};

export default nextConfig;

Because @t3-oss/env-nextjs calls createEnv at module load time, importing it in next.config.ts ensures validation runs before Next.js starts. A bad deploy fails fast at the startup phase, not in production traffic.

Step 5: Handle Per-App Variable Overrides

Some variables differ between apps — for example, NEXT_PUBLIC_APP_URL is different for apps/web and apps/admin. The packages/env shared package can’t know these at authoring time.

My solution: export a factory function instead of a singleton.

ts

// packages/env/index.ts (factory version)
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export function createAppEnv(appEnv: { NEXT_PUBLIC_APP_URL: string }) {
  return createEnv({
    server: {
      DATABASE_URL: z.string().url(),
      NODE_ENV: z.enum(["development", "test", "production"]),
    },
    client: {
      NEXT_PUBLIC_APP_URL: z.string().url(),
    },
    runtimeEnv: {
      DATABASE_URL: process.env.DATABASE_URL,
      NODE_ENV: process.env.NODE_ENV,
      NEXT_PUBLIC_APP_URL: appEnv.NEXT_PUBLIC_APP_URL,
    },
  });
}

ts

// apps/admin/env.ts
import { createAppEnv } from "@repo/env";

export const env = createAppEnv({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_ADMIN_URL!,
});

This keeps shared validation centralized while allowing each app to supply its own variable binding.


How Do You Safely Manage Secrets in a Turborepo CI/CD Pipeline?

With the structure in place, the remaining risk is secret injection. Here’s how I handle it in production.

Use Doppler or Infisical for secret injection. I sync secrets into Vercel, GitHub Actions, and local dev from a single source of truth. Running doppler run -- turbo build injects all secrets into the process environment before Turbo runs.

Separate build-time from runtime variables. Variables used in generateStaticParams or at build time must be available during turbo build. Variables only needed at request time (like session secrets) don’t need to be in env in turbo.json, but they do need to be in your deployment environment.

Audit NEXT_PUBLIC_ variables before every deploy. In CI, I run a quick check:

bash

# Print all NEXT_PUBLIC_ vars being used (audit before deploy)
grep -r "NEXT_PUBLIC_" apps/ --include="*.ts" --include="*.tsx" | \
  grep -oP 'NEXT_PUBLIC_\w+' | sort -u

Compare this list against what’s actually declared in turbo.json. Any mismatch is a potential cache bug.


Common Errors and How I Fixed Them

Error: Turborepo cache not invalidating when env var changes
Cause: The variable wasn’t listed in turbo.json‘s env array for that task. Add it explicitly — Turbo does not auto-detect env var usage.

Error: process.env.X is undefined in a Client Component
Cause: The variable is missing the NEXT_PUBLIC_ prefix. Server-side variables are stripped from the client bundle. Rename it and update turbo.json.

Error: @t3-oss/env-nextjs throws Invalid environment variables with no details
Fix: Add .catch((err) => { console.error(err.issues); process.exit(1); }) or check the ZodError output. The library prints a formatted error table, but only if your terminal renders it — in some CI environments you need to check stderr explicitly.

Error: dotenv-cli loads variables but Turborepo still misses them
Cause: Turborepo reads env variables from the process at invocation time, after dotenv loads them. Make sure dotenv-cli wraps the entire turbo command, not just the script before it.


FAQ

Q: How do I share environment variables across multiple Next.js apps in a Turborepo monorepo?
A: Create a packages/env package with @t3-oss/env-nextjs that exports validated env objects. Each app imports from this shared package, ensuring consistent types and validation logic across the entire monorepo.

Q: Why does Turborepo serve a stale build when I change an environment variable?
A: Turborepo only invalidates its cache for variables explicitly listed in the env array of each task in turbo.json. If the variable is missing from that list, Turbo won’t include it in the cache hash and will ignore the change.

Q: How should I handle secret environment variables in a Next.js Turborepo CI/CD pipeline?
A: Never commit secrets to .env files in your repo. Use a secrets manager like Doppler, Infisical, or your cloud provider’s secret store. In GitHub Actions, inject secrets via environment variables at the workflow level and reference them in the turbo build command.

Q: What is the difference between globalEnv and per-task env in turbo.json?
A: globalEnv affects all tasks — a change busts the entire cache. Per-task env only busts the cache for that specific task. Use globalEnv for variables like NODE_ENV and CI, and per-task env for app-specific variables like DATABASE_URL.

Q: How do I prevent Next.js from accidentally leaking server environment variables to the browser in a monorepo?
A: Use @t3-oss/env-nextjs, which separates server and client schemas. Client-side variables must start with NEXT_PUBLIC_. If you try to access a server variable in a Client Component, you’ll get a build-time error rather than a silent undefined.


Conclusion

After the staging-hits-production incident, I invested a full sprint into getting our environment variable story right. The combination of a shared packages/env package, @t3-oss/env-nextjs validation, and explicit Turborepo env declarations has eliminated an entire class of bugs and deployment surprises for our team.

If you’ve been managing .env files by hand across multiple apps, try the shared package approach — even for a small monorepo. The upfront cost is about two hours, and the payoff is permanent. Share this article if it saved you a debugging session, and drop a comment with how your team handles secrets across environments.


About the Author

I’m a senior software engineer with 9 years of experience specializing in full-stack TypeScript, Next.js monorepos, and developer tooling. I’ve built and maintained production monorepos with up to 12 apps and 20 shared packages, and I’m opinionated about the infrastructure details that teams usually overlook until they cause incidents. My current stack revolves around Next.js, Turborepo, Supabase, and Vercel.After the staging-hits-production incident, I invested a full sprint into getting our environment variable story right. The combination of a shared packages/env package, @t3-oss/env-nextjs validation, and explicit Turborepo env declarations has eliminated an entire class of bugs and deployment surprises for our team.


About the Author

I’m a senior software engineer with 9 years of experience specializing in full-stack TypeScript, Next.js monorepos, and developer tooling. I’ve built and maintained production monorepos with up to 12 apps and 20 shared packages, and I’m opinionated about the infrastructure details that teams usually overlook until they cause incidents. My current stack revolves around Next.js, Turborepo, Supabase, and Vercel.