How the Nx task hash is formed and why ambient env is ignored

Before running a cacheable task, Nx computes a hash from a fixed set of sources. If a matching hash exists in the local or remote cache, Nx replays the stored terminal output and restores the recorded output files instead of running the task. The hash only covers sources Nx has been told about:

  • Project files matched by the target's input globs, usually supplied through named inputs such as default or production.
  • Dependency inputs, pulled in with the ^ prefix (for example ^production). These cover the source files of upstream projects. Upstream build artifacts are included only if you declare dependentTasksOutputFiles.
  • Task configuration: the target's options and the arguments the task was invoked with.
  • External dependencies: resolved package versions from the lockfile.
  • Declared env inputs: the values of specific environment variables you list by name.
  • Declared runtime inputs: the output of a command you specify, such as a tool version check.

project files (input globs) ─┐ dependency inputs (^) ──────┤ task options and args ────────┤ external dependencies ────────┼──▶ task hash ──▶ cache lookup declared env values ────────┤ declared runtime output ────┘

The process environment is not on this list. Nx does not read your shell's variables and fold them into the hash. If it did, a CI runner with HOSTNAME, PATH and build IDs that change on every run would never get a cache hit.

The contract is strict: Nx hashes only what you declare. Suppose API_URL changes what a build produces but is not declared as an input. Two runs with different values will produce the same hash. The second run silently restores the first run's artifact, and nothing reports an error.

 DECLARED INPUTS (hashed)
+----------------------------------+
| project files (input globs)      |---+
+----------------------------------+   |
| dependency inputs (^production)  |---+
+----------------------------------+   |
| task options and args            |---+
+----------------------------------+   |     +-----------+    +--------------+
| external deps (lockfile)         |---+---->| task hash |--->| cache lookup |
+----------------------------------+   |     +-----------+    +-------+------+
| env: API_URL (declared)          |---+                              |
+----------------------------------+   |                              |
| runtime: node --version          |---+                              |
+----------------------------------+                                  v
                                             hit:  replay log, restore files
                                             miss: run task, store result

 NOT HASHED (ambient env, unless declared)
+----------------------------------+
| PATH, HOSTNAME, CI build IDs,    |---X  never read into the hash
| any var you forgot to declare    |
+----------------------------------+

 Undeclared var changes output -> same hash -> stale artifact restored,
 no error reported.

Nx combines only declared sources (input globs, ^ dependency inputs, task options, lockfile, named env vars and runtime command output) into the task hash, and never reads the ambient process environment, so an undeclared variable that affects output produces a silent wrong cache hit.

Declaring env and runtime inputs in nx.json and project targets

The fix is to make the variable part of the hash. Nx supports this in two places, and which one you choose decides how far a change spreads.

Per-target inputs

In a project's target configuration, add { "env": "API_URL" } to that target's inputs array, next to file inputs such as "production" or "^production". Nx then reads the variable's value when it computes the hash. Changing API_URL produces a new hash and a cache miss for that target only. Use this when one target consumes the variable, for example a frontend build that inlines API_URL while the lint and test targets never read it.

Shared named inputs

In nx.json, a namedInputs entry can include the same object, for example a production entry that lists the default files plus { "env": "API_URL" }. Every target that references production in its inputs, directly or through targetDefaults, now depends on the variable. This fits values that genuinely affect many projects, such as NODE_ENV. It is also expensive. Changing the variable invalidates the cache for every project and target that uses the named input, including projects that never read it.

Runtime inputs

{ "runtime": "node --version" } hashes a command's output. Use it for toolchain facts such as the Node version or the OS (uname -s). Nx runs the command each time it computes a hash, so the command must be fast. It must also be deterministic. If the output includes a timestamp, a PID, or network-dependent text, every run produces a different hash, and the cache will never hit.

// nx.json (shared: every target that references these named inputs is affected)
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [
      // Toolchain facts. Commands must be fast and print stable output.
      { "runtime": "node --version" },
      { "runtime": "uname -s" }
    ],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      // Changing NODE_ENV invalidates every target that uses "production"
      { "env": "NODE_ENV" }
    ]
  },
  "targetDefaults": {
    "build": { "inputs": ["production", "^production"], "cache": true }
  }
}

// apps/web/project.json (only this project's build reads API_URL)
{
  "name": "web",
  "targets": {
    "build": {
      // Target-level inputs replace the targetDefaults list, so repeat the file inputs
      "inputs": ["production", "^production", { "env": "API_URL" }]
    }
  }
}

A shared namedInputs entry in nx.json hashes NODE_ENV and runtime toolchain facts for every target that uses it, while a single project's build target adds API_URL to its own inputs so a change to that variable invalidates only that target's cache.

How Nx loads .env files versus whether it hashes them

Nx reads .env files before running a task and injects their values into the task's process environment. That step is separate from hashing. A file can set variables for every run and still have no effect on the cache key. Loading changes what the task sees. Hashing changes only whether Nx thinks the task has already run.

Nx looks for files at the workspace root and at the project root. It also supports variants scoped to a target, such as a build-specific file, and to a configuration, such as a production variant of that target. More specific files take precedence over general ones. The exact file names and the precedence order have changed between releases, so check the docs for your installed Nx version before you rely on either.

File location Loaded into env Hashed by default
Project root (apps/api/.env) Yes Usually, through a {projectRoot}/**/* input
Workspace root (.env) Yes No, unless you add it to a shared named input
Gitignored .env.local Only where it exists Only where it exists

Hashing follows inputs, not loading. A project-root file falls under the default project file glob. A workspace-root file sits outside every project, so you have to add it to a shared input that targets include.

Run nx show project <name> to see the resolved inputs for each target instead of guessing.

Gitignored local files cause a quieter problem. If a developer's .env.local is hashed, their task hashes include it and CI's hashes do not. Those hashes never match, so local runs miss the remote cache even when the code is identical. Either keep local overrides out of hashed inputs, or accept that local runs will not share cache entries with CI.

{
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/.env"
    ],
    "default": [
      "{projectRoot}/**/*",
      "!{projectRoot}/.env.local",
      "sharedGlobals"
    ],
    "production": [
      "default"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["production", "^production"]
    }
  }
}

An nx.json fragment that hashes the workspace-root .env through sharedGlobals and excludes a gitignored project .env.local so local and CI hashes can match; verify the resolved inputs with nx show project .

Build-time inlining versus runtime configuration: who needs env inputs

The deciding question for any variable is whether its value ends up inside the task's output. Bundlers and code generators often replace references such as process.env.API_URL with the literal string at build time. When that happens, the variable is a real build input. If it is not declared, Nx can return an artifact built against a different value and report a cache hit. A backend service that reads configuration at startup has the opposite property. Its compiled output is identical whatever the value is, so declaring the variable only splits the cache without making it more correct.

Variable type Typical use Declare as env input?
Feature flag inlined by the bundler Dead-code elimination in frontend builds Yes
Feature flag read at runtime Service toggles via config or a flag service No
API base URL inlined into a client bundle Browser app calling a backend Yes
API base URL read by a service at startup Backend-to-backend calls No
Secret Database passwords, signing keys No. Never inline it at all
Build number or commit SHA Version stamping Only if written into output, and expect a miss on every build
Hostname Bind address, service discovery No

Inlined secrets are a security problem before they are a caching problem. The secret sits in plain text in the artifact, and a shared remote cache serves that artifact to every machine and CI job that can read the cache. A leak in one build then spreads to every consumer of the cache. If a task truly depends on a secret, declaring it is safe in one respect: only its hashed contribution enters the task hash, so the value itself is not stored. Rotating the secret changes the hash and invalidates every entry built with the old value.

When to avoid declaring variables: over-declaration and hit-rate loss

The same mechanism works against you when a declared variable changes constantly. Declare BUILD_NUMBER, a timestamp, HOSTNAME, or a value each developer sets in .env.local, and every run or machine produces a different hash. Nx still computes hashes, uploads artifacts, and checks the remote cache, but it almost never finds a match. Caching is effectively off, and you still pay for storage and network transfer. Because inputs propagate to dependent tasks, one volatile variable on a shared library can force misses across much of the workspace.

Runtime inputs carry a separate cost. Nx runs the declared command every time it computes the hash, including on runs that end up as cache hits. A command that calls the network, scans a large directory, or prints a timestamp adds latency to every run. If its output is non-deterministic, it also causes misses when the code hasn't changed. Runtime inputs should be fast, local, and stable for identical inputs.

Most volatile variables can be replaced:

  • Use configurations for environment differences. production and development configurations each get their own task identity, hash, and cached outputs. Switching between them doesn't evict the other's entries.
  • Read values at runtime when they don't need to be compiled in. A build number shown in a health endpoint can come from the deployment environment at startup, so the build artifact stays identical across runs.
  • Keep local overrides out of the shared cache. Artifacts built with personal .env.local values shouldn't be written to the remote cache that CI reads from. Restrict remote cache writes to CI, or exclude override-dependent targets from caching.
// apps/api/project.json (Nx parses comments in project.json)
{
  "name": "api",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "cache": true,
      "outputs": ["{workspaceRoot}/dist/apps/api"],
      "inputs": [
        "production",
        "^production",
        // Stable per environment: changes only when the target API changes.
        { "env": "API_BASE_URL" },
        // Fast, local, deterministic runtime input.
        { "runtime": "node --version" }
        // Deliberately NOT declared: BUILD_NUMBER, HOSTNAME, timestamps.
        // The server reads BUILD_NUMBER from process.env at startup,
        // so the build artifact stays identical across CI runs.
      ],
      "options": {
        "command": "node tools/build-api.mjs --mode=development"
      },
      "defaultConfiguration": "production",
      // Each configuration gets its own hash and cached outputs,
      // so switching between them does not evict the other's entry.
      "configurations": {
        "production": {
          "command": "node tools/build-api.mjs --mode=production"
        },
        "development": {
          "command": "node tools/build-api.mjs --mode=development"
        }
      }
    }
  }
}

A build target that hashes only stable inputs and uses production and development configurations instead of one mutable variable, while BUILD_NUMBER is left out of the hash and read when the server starts.

Audit checklist and debugging stale cache hits

Work through this list once per buildable project, and again whenever a build or codegen script changes.

Find every read

  1. Search the code that runs at build time, not only application source: grep -rnE "process\.env|import\.meta\.env" tools/ scripts/ apps/*/vite.config.* apps/*/webpack.config.*. Bundler configs, codegen scripts and custom executors are where undeclared reads tend to hide, because nobody treats them as "app code."
  2. For each hit, ask one question: does a different value produce a different file in the output directory? If it does, the variable belongs in the target's inputs as { "env": "NAME" }. If it only affects logging or runtime behavior, leave it out (see the previous section).
  3. Run nx show project <name> and confirm the resolved inputs for the target include what you declared. Named inputs and target defaults merge, so the effective list can differ from what you expect.

Prove each declaration

  1. Build once, then change one variable's value and build again. A cache miss is the expected result. A hit means the declaration isn't wired to that target, or the read happens in a dependency whose inputs don't include it. Repeat this for every variable. A partial list fails silently.

Debugging a suspected stale hit

  1. Compare the task hashes from the two runs, using whatever run details your setup records. Identical hashes for builds you expected to differ point to a missing input.
  2. Rerun with nx build <name> --skip-nx-cache. If the output now differs from the cached artifact, the cache was serving the wrong result.
  3. Run nx reset to clear the local cache and daemon state before you retest. This rules out stale project-graph data as the cause.

Impact on cost and engineering time

Under-declaring and over-declaring do not cost the same. Under-declaring fails silently. A task that reads an undeclared variable gets a hash that ignores it, so the first artifact built with the wrong value is replayed as a valid hit. With a shared remote cache, that artifact reaches every developer machine and every CI pipeline, and no build fails. The cost appears later as a debugging session spent on a bug that exists only in cached output. Over-declaring fails visibly. Hashes change more often than outputs do, the hit rate drops, and you pay in CI minutes.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: 400 cacheable task runs per day and 3 minutes per cache miss.

Formula: daily rebuild minutes = runs × (1 − hit rate) × minutes per miss

Scenario Assumed hit rate Daily rebuild minutes Additional cost
Inputs declared precisely 80% 400 × 0.20 × 3 = 240 None
Over-declared (e.g. a per-commit value in env inputs) 40% 400 × 0.60 × 3 = 720 480 extra CI minutes per day
Under-declared 85% 400 × 0.15 × 3 = 180 One poisoned artifact. Assume 2 engineers × 4 hours = 8 engineer-hours

The under-declared row looks cheapest on CI time, but that saving disappears after one incident, because an engineer-hour usually costs far more than a CI minute.

You can avoid both failure modes by treating env and runtime inputs as part of the build contract. When a change makes a target read a new variable, the same pull request adds that variable to the target's inputs, and reviewers check it the way they check a new dependency. The fix then costs one reviewed line in a pull request instead of an open-ended investigation.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx env variables cache, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.