Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Fixing Missing Environment Variables in Turborepo Strict Mode

Turborepo 2 runs tasks in strict environment mode by default: a task only sees environment variables that turbo.json declares, plus a small allowlist of system variables. Anything else is filtered out before the task starts. The result is correct caching — every variable that can change output is part of the hash — but the first time you hit it, a build that worked for months suddenly cannot see DATABASE_URL, NEXT_PUBLIC_API_URL or NPM_TOKEN. This guide explains what strict mode filters, the three places to declare variables, how to find the undeclared ones, and how to avoid the opposite mistake of declaring everything.

Exact symptoms and error messages

Strict mode failures look like missing configuration inside the task:

@acme/api:build: Error: Environment variable DATABASE_URL is not set
@acme/api:build: ERROR: command finished with error: command (/repo/apps/api) pnpm run build exited (1)
@acme/web:build: ⚠ Invalid environment variables: { NEXT_PUBLIC_API_URL: [ 'Required' ] }

Framework builds sometimes succeed with wrong output instead, inlining undefined:

# grep in the built bundle
apps/web/.next/static/chunks/app-7c1a.js: fetch(`${undefined}/graphql`)

Private registry installs inside tasks fail because the token is filtered:

@acme/tools:generate: npm error code E401
@acme/tools:generate: npm error Unable to authenticate, need: Basic realm="GitHub Package Registry"

The giveaway is that the variable is present in the shell — echo $DATABASE_URL prints it — yet the task behaves as if it were unset. Running the same script directly with pnpm --filter @acme/api run build, bypassing Turborepo, usually works, which confirms that the filtering happens in the task runner rather than in your code or your CI configuration.

Root cause analysis

Before running a task, Turborepo builds the task's environment from an allowlist. In strict mode that allowlist is: variables in the task's env and passThroughEnv, variables in globalEnv and globalPassThroughEnv, and a built-in set of system variables such as PATH, HOME, SHELL and CI provider basics. Everything else is removed. The task model is covered in Turborepo Pipeline Configuration.

How strict mode builds a task's environment The shell environment is filtered to declared env, passThroughEnv and global variables plus system basics; only hashed variables affect the cache key. shell environment DATABASE_URL, NPM_TOKEN, CI, ... strict filter keep declared + system basics hash env + globalEnv values become part of the key task runs sees only allowed variables
Declared variables pass through; hashed ones also change the cache key; undeclared ones never reach the task.

The reason for the design is caching correctness. In loose mode, a task could read any variable, but only declared ones were hashed. Two builds with different NEXT_PUBLIC_API_URL values produced the same hash, so staging could replay production's build output. Strict mode makes "the task can read it" and "the cache knows about it" the same set, apart from the explicit pass-through escape hatch.

Resolution: declare each variable in the right place

There are three kinds of declaration, and choosing between them is the actual fix.

Where to declare an environment variable Variables that change output go in env or globalEnv and are hashed; variables that must be available but do not change output go in passThroughEnv. Does the value change the task's output? bundle contents, generated files, test results tasks.build.env hashed, per task yes, one task globalEnv hashed for all tasks yes, every task passThroughEnv available, not hashed no, just needed
Hash what changes output; pass through what only needs to be present.
{
  "$schema": "https://turborepo.com/schema.json",
  "globalEnv": ["NODE_ENV"],
  "globalPassThroughEnv": ["CI", "GITHUB_ACTIONS"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "env": ["NEXT_PUBLIC_*", "API_URL", "SENTRY_RELEASE"],
      "passThroughEnv": ["NPM_TOKEN", "SENTRY_AUTH_TOKEN"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "test": {
      "env": ["DATABASE_URL"],
      "passThroughEnv": ["TEST_DB_PASSWORD"]
    }
  }
}
  • env — the variable changes this task's output. NEXT_PUBLIC_* values are inlined into bundles, so they must be hashed. Wildcards are supported, and ! excludes names.
  • globalEnv — the variable changes every task's output, such as NODE_ENV.
  • passThroughEnv / globalPassThroughEnv — the task needs the value but its output does not depend on it: authentication tokens, telemetry keys, CI metadata. Hashing them would break caching (tokens rotate) and would not improve correctness.

Secrets such as SENTRY_AUTH_TOKEN and NPM_TOKEN belong in pass-through; putting them in env does not leak them (Turborepo hashes values rather than storing them), but it causes needless cache misses when they rotate.

Finding every undeclared variable

Search the code, then let Turborepo confirm:

# Direct reads across the repository
grep -rhoE "process\.env\.[A-Z0-9_]+" apps packages --include=*.{ts,tsx,js,mjs} | sort | uniq -c | sort -rn

# Framework-specific conventions
grep -rhoE "import\.meta\.env\.[A-Z0-9_]+" apps packages | sort -u

# Ask Turborepo which variables each task saw and hashed
pnpm turbo run build --dry=json | jq '.tasks[] | {taskId, env: .environmentVariables}'

Remember indirect reads: configuration libraries that load .env files, framework conventions that inline prefixed variables automatically, and tools invoked by your scripts (a test runner reading CI, a CLI reading GITHUB_TOKEN). For .env files, add them to the task's inputs so file changes invalidate the cache: "inputs": ["$TURBO_DEFAULT$", ".env*"].

Framework inference

Turborepo automatically treats well-known framework prefixes as declared for packages using those frameworks: NEXT_PUBLIC_* for Next.js, VITE_* for Vite, NUXT_PUBLIC_* for Nuxt, and others. That covers the most common case, but it only applies when Turborepo detects the framework in the package's dependencies, and it does not cover unprefixed variables your framework configuration reads at build time. Check the dry-run output rather than assuming inference covered you. Inference can be turned off with --framework-inference=false if you prefer every variable listed explicitly.

Monorepo patterns for environment declarations

In a repository with many applications, a single root turbo.json listing every variable becomes long and hard to review. Two patterns keep it manageable.

Package-level declarations. Each application declares its own variables in a package-level turbo.json that extends the root, so apps/web lists NEXT_PUBLIC_* and apps/api lists DATABASE_URL without either list affecting the other's hash. Package configurations are covered in Using Package-Level turbo.json Overrides.

Validated environment schemas. Many teams validate environment variables at startup or build time with a schema (for example, with Zod). The same schema is a natural source of truth for declarations: a small script can read each application's schema and check that every variable it defines appears in that package's env or passThroughEnv, failing CI if one is missing.

Environment declarations split by package The root turbo.json declares global variables, while each application's turbo.json declares the variables only that application reads. repo/ turbo.json globalEnv: NODE_ENV; globalPassThroughEnv: CI apps/web/turbo.json build.env: NEXT_PUBLIC_* apps/api/turbo.json build.env: API_URL; test.env: DATABASE_URL packages/tools/turbo.json passThroughEnv: NPM_TOKEN
Keeping declarations next to the code that reads them makes both hashing and review more precise.

Keeping declarations close to the code that reads them also narrows cache invalidation: a change to NEXT_PUBLIC_API_URL invalidates only the web application's build, not the API's.

Loose mode as a temporary bridge

pnpm turbo run build --env-mode=loose

Loose mode passes the full environment to tasks, restoring 1.x behaviour. It is useful to confirm that a failure is caused by filtering — if loose mode fixes it, a declaration is missing. Do not leave it on: it reintroduces the risk of cache hits across environments with different values.

Worked example: staging served production's bundle

Before a team adopted strict mode, their staging deployment occasionally showed production data. Both environments built the web application with the same inputs except NEXT_PUBLIC_API_URL, which was not declared, so the remote cache returned whichever build ran first. After upgrading to Turborepo 2, strict mode filtered the variable out entirely and builds failed — an unpleasant but useful failure. Declaring NEXT_PUBLIC_* under env for the build task made the URL part of the hash: staging and production now get separate cache entries, and the cross-environment mix-up became impossible.

CLI validation and debug commands

# See the effective environment mode and variables per task
pnpm turbo run build --dry=json | jq '.envMode, (.tasks[] | {taskId, environmentVariables})'

# Summarise a real run, including env hashes, into .turbo/runs/
pnpm turbo run build --summarize

# Confirm a variable reaches a task: add a temporary script that prints it,
# e.g. "env:check": "node -e \"console.log(!!process.env.DATABASE_URL)\"",
# declare the task in turbo.json, then run it through turbo (not pnpm directly)
pnpm turbo run env:check --filter=@acme/api

Prevention and CI/CD guardrails

  • Keep strict mode on and treat undeclared variables as bugs.
  • Hash what changes output; pass through what does not.
  • Add .env* files to task inputs when tasks read them.
  • Review environment declarations when adding a new package or framework, and check a dry run in CI.

Frequently Asked Questions

Does declaring a secret in env store it in the cache? No. Turborepo hashes values; it does not store variable values in the cache. Pass-through is still better for secrets, because rotating them should not invalidate caches.

Why does my variable work locally but not in CI? Locally the variable may come from a .env file your framework loads itself, which strict mode does not filter because it is read from disk. In CI it comes from the environment, which strict mode does filter. Declare it either way.

Can I declare all variables with a wildcard? "env": ["*"] technically works but hashes every variable, including ones that change on every run such as timestamps and run IDs, so the cache never hits. Declare the variables tasks actually use.

Which system variables does strict mode always allow? A small built-in set that tools need to function, such as PATH, HOME, SHELL, temporary-directory variables and some CI provider variables. Anything specific to your application or tooling must be declared.

Does strict mode affect variables loaded from .env files by my framework? No. Strict mode filters the process environment that Turborepo passes to the task. A framework that reads .env files from disk inside the task still sees them, which is why .env* files should be in the task's inputs so changes to them invalidate the cache.

Related

Turborepo Pipeline Configuration