What goes into an Nx task hash, and what is left out

An Nx task hash is a fingerprint of everything Nx has been told can affect a task's output. If two runs produce the same hash, Nx assumes the outputs are identical and replays the cached terminal output and artifacts. The hash includes:

  • Resolved file inputs. These are the file sets from the target's inputs, expanded through any namedInputs they reference. A ^ prefix pulls in the matching inputs of the project's dependencies, so a change in a library invalidates the apps that consume it.
  • The command and its options. This covers the target, its executor or command, and the configuration and arguments passed to it.
  • External dependency versions. These are the versions of the third-party packages the project depends on, as resolved in the project graph.
  • Declared env and runtime entries. These are the values of named environment variables, and the output of commands you explicitly list.

Here is what feeds the hash and what does not:

Inside the hash: file inputs (incl. ^ dependencies) + command and options + external dependency versions + declared env / runtime values → task hash

Outside the hash: every other variable in the process environment (API_URL, NODE_ENV, feature flags…). The task still reads these at execution time.

The process environment is not hashed by default. Your build can read API_URL while Nx ignores it when computing the hash. If you change that variable and rerun, the hash does not change, and Nx restores output built against the old value. The terminal output looks normal and the artifacts are present, so nothing signals that the result is stale.

Under-declaring an input also fails silently. Nx has no way to see which variables your tooling actually reads. When a dependency is missing from the hash, the only symptom is a cache hit that should have been a miss.

+--------------------------+      +--------------------------+
| File inputs              |      | Command and options      |
| inputs + namedInputs     |      | executor or command,     |
| ^ dependency inputs      |      | configuration, args      |
+------------+-------------+      +------------+-------------+
             |                                 |
             v                                 v
+------------------------------------------------------------+
|                         TASK HASH                          |
+------------------------------------------------------------+
             ^                                 ^
             |                                 |
+------------+-------------+      +------------+-------------+
| External dep versions    |      | Declared env / runtime   |
| (from project graph)     |      | named vars, listed cmds  |
+--------------------------+      +--------------------------+

- - - - - - - - - - - - outside the hash - - - - - - - - - - -

+--------------------------+      +--------------------------+
| Undeclared process env   |----->| Task execution           |
| API_URL, NODE_ENV,       |      | reads values at run time |
| feature flags ...        |      | hash unchanged -> stale  |
+--------------------------+      +--------------------------+

Declared file inputs, command and options, external dependency versions, and env/runtime entries feed the Nx task hash. The rest of the process environment is read at run time but never hashed.

The non-file input forms: env, runtime and externalDependencies

Besides file globs, a target's inputs array accepts three object forms. They differ in what Nx reads to compute the hash and in what that read costs.

  • { "env": "API_URL" } hashes the value of one environment variable. If the variable is unset, that absence is part of the hash, so changing it from unset to set also invalidates the cache.
  • { "runtime": "node --version" } runs a shell command and hashes its stdout. Use it for toolchain versions or derived values that no file captures, such as a compiler version or a platform identifier.
  • { "externalDependencies": ["jest", "ts-jest"] } limits which installed package versions count toward the hash to the packages you list. Without it, a lockfile bump to an unrelated package can invalidate tasks that never load that package.
Form What it hashes Typical use Cost
env One variable's value Build-time config, feature flags Negligible
runtime A command's stdout Toolchain versions, derived values One process spawn per hash
externalDependencies Versions of the listed packages Avoiding invalidation from unrelated lockfile changes Negligible, reduces misses

Runtime inputs need more care than the other two:

  • Latency. Nx runs the command before it can look up the cache, so a slow command delays every cache hit.
  • PATH dependence. The command resolves against the PATH of the machine running Nx. If the tool is missing on a CI runner, or a different binary appears first on PATH, that machine computes a different hash.
  • Nondeterminism. Output that contains a timestamp, a hostname or an absolute path produces a different hash on every machine. Remote cache sharing then stops working.

Check these forms against the documentation for the Nx version you have installed, because input syntax has changed between releases.

{
  "name": "web-app",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "options": {
        "command": "vite build",
        "cwd": "apps/web-app"
      },
      "inputs": [
        "default",
        "^default",
        { "env": "API_URL" },
        { "runtime": "node --version" },
        { "externalDependencies": ["vite"] }
      ]
    },
    "test": {
      "executor": "nx:run-commands",
      "options": {
        "command": "jest",
        "cwd": "apps/web-app"
      },
      "inputs": [
        "default",
        "^default",
        { "externalDependencies": ["jest", "ts-jest"] }
      ]
    }
  }
}

A project.json where build hashes API_URL, the Node version and the vite version, while test counts only jest and ts-jest; verify the syntax against your installed Nx version.

How to implement env inputs with namedInputs and targetDefaults

Put the variables in one named input in nx.json, so every target refers to them by a single name. A buildEnv entry under namedInputs might hold { "env": "API_BASE_URL" } and { "env": "PUBLIC_FEATURE_FLAGS" }. In targetDefaults, the build target then references it next to the file inputs: "build": { "inputs": ["production", "^production", "buildEnv"] }. Adding a variable later means editing one list, and every build that uses buildEnv picks it up on the next run.

The risk is in how overrides work. When a project defines its own inputs array for build, that array replaces the one from targetDefaults. The two are not merged. Suppose a team adds "inputs": ["production", "^production", "{projectRoot}/schema/**/*"] to pick up generated schema files. They have also removed buildEnv from that project's hash. Nothing fails. Builds stay green, and the cache serves artifacts compiled against whatever API_BASE_URL was set when the entry was first written.

The fix is to restate every input you want to keep: "inputs": ["production", "^production", "buildEnv", "{projectRoot}/schema/**/*"]. Treat any project-level inputs as a full declaration, not an addition.

Each approach has costs:

  • Centralizing in targetDefaults is the safer default because new projects inherit the env entries automatically. It makes audits harder, because a project's effective inputs depend on nx.json and on whether that project overrides them.
  • Per-project configuration puts the full list in front of the reviewer. It drifts as variables are added to some projects and forgotten in others.

A workable compromise is to centralize the inputs and add a CI check that flags any project-level inputs for build that omits buildEnv.

// nx.json: define the env inputs once and reference them from targetDefaults
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    "buildEnv": [
      { "env": "API_BASE_URL" },
      { "env": "PUBLIC_FEATURE_FLAGS" }
    ]
  },
  "targetDefaults": {
    "build": {
      "inputs": ["production", "^production", "buildEnv"]
    }
  }
}

// apps/web/project.json, BROKEN: this array replaces the targetDefaults inputs,
// so buildEnv is silently dropped from this project's hash
{
  "targets": {
    "build": {
      "inputs": ["production", "^production", "{projectRoot}/schema/**/*"]
    }
  }
}

// apps/web/project.json, FIXED: restate every input you want to keep
{
  "targets": {
    "build": {
      "inputs": ["production", "^production", "buildEnv", "{projectRoot}/schema/**/*"]
    }
  }
}

A shared buildEnv named input referenced from targetDefaults, a project-level override that drops it from the build hash, and the corrected override that lists buildEnv again (the // file labels are annotations and are not part of the JSON).

Who should declare env inputs and when: build-time vs. runtime variables

That check only works if someone decides what belongs in the central list. The rule: a variable belongs in a task's inputs only if the cached task reads it while it runs and its value can change the outputs. If Nx replays the outputs, the variable had no chance to affect them unless it was hashed.

Three common cases qualify. The first is values a bundler inlines at build time, such as process.env.API_BASE_URL replaced with a string literal; the artifact now contains that value. The second is code generation that reads env to decide what to emit. The third is test configuration that switches behavior based on env, such as a feature flag or a fixture mode.

Variables the deployed service reads only when it starts, such as DATABASE_URL or PORT resolved at boot, do not belong in inputs. The build output is identical whatever their value. Declaring them splits one valid cache entry into one per environment. Rotating a value then invalidates builds that never used it.

This is the job of the platform and backend engineers who own nx.json and the shared presets. Application teams add env reads without realizing the hash depends on them.

Audit checklist

  • Search build configs, custom executors and scripts for process.env and similar env reads.
  • List what the bundler's define or replace mechanism inlines, including prefix-based exposure rules that pass through whole groups of variables.
  • Check test setup files and global config for env-dependent branches.
  • For each runtime-only read, confirm it is absent from inputs.
#!/usr/bin/env bash
# Audit env reads that may need to be declared as Nx task inputs.
# Run from the workspace root. Review each hit by hand: ask whether the
# cached task (build, codegen, test) reads it, or only the app at boot.
set -euo pipefail

EXCLUDES=(--exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.nx)

echo "== 1. Env reads in build configs, executors and scripts =="
# Bundler configs, custom executors and tooling scripts run during the task.
grep -rnE "process\.env|import\.meta\.env" "${EXCLUDES[@]}" \
  --include='*.config.*' --include='project.json' \
  tools scripts . 2>/dev/null | sort -u || true

echo "== 2. Values the bundler inlines at build time =="
# 'define' and replace plugins bake values into the artifact, so they
# must be hashed. Prefix rules (e.g. VITE_, NEXT_PUBLIC_) expose whole groups.
grep -rnE "define[[:space:]]*:|DefinePlugin|envPrefix|NEXT_PUBLIC_|VITE_" \
  "${EXCLUDES[@]}" --include='*.config.*' . || true

echo "== 3. Env-dependent branches in test setup =="
# Feature flags or fixture modes here change test results, so they count.
grep -rnE "process\.env" "${EXCLUDES[@]}" \
  --include='*setup*' --include='jest.config.*' --include='vitest.config.*' . || true

echo "== 4. Env names currently declared as inputs =="
# Compare against the runtime-only reads (DATABASE_URL, PORT, ...):
# those should NOT appear here.
grep -rnoE '"env"[[:space:]]*:[[:space:]]*"[A-Z0-9_]+"' "${EXCLUDES[@]}" \
  --include='nx.json' --include='project.json' . || true

A grep-based audit script that lists env reads in build configs, bundler inline rules and test setup, then prints the env names already declared as Nx inputs so runtime-only variables can be spotted and removed.

When to avoid caching: secrets, side effects and volatile values

Declaring a secret as an env input is safe as far as the hash goes. Nx folds the variable's value into the task hash, and the hash does not reveal the value. The danger is in the outputs. If a bundler inlines API_KEY into a JavaScript file, that file is stored in the cache. With a remote cache, anyone who can read the cache can download the artifact and extract the key, even if they never had access to the secret. To avoid this, keep secrets out of cacheable outputs. Inject them at runtime, or read them in a non-cacheable step that runs after the build.

Volatile values cause a different problem. Timestamps, commit SHAs and CI build numbers change on every run. Once one of them is an input, every hash is unique and the hit rate drops close to zero. If one of them is inlined without being declared, the result is a false hit that serves a stale value. Stamp version metadata in a separate final step instead.

Some tasks should not be cached at all. Turn off caching for the target instead of trying to list every input:

Task type Model inputs? Mark non-cacheable? Why
Deploy, publish No Yes Replaying the output does not repeat the side effect
Side-effecting scripts (migrations, uploads) No Yes The cache cannot capture external state
Reads many env vars you cannot enumerate Not reliably Yes Unlisted variables cause false hits
Deterministic build with a few known vars Yes No Inputs are complete and bounded

Diagnosing cache misses, false hits and CI parity

When a cached result looks wrong, or a task you expected to replay runs again, compare the hash inputs instead of guessing. Run the same target twice, once in each environment, and capture the hash details your installed Nx version exposes. Depending on the version, that may be a hash-details view, verbose task output, or the task graph. Check nx --help and the docs for your version instead of relying on a flag you remember. Diff the two sets of inputs. The entry that differs explains the miss.

A suspected false hit needs a different test. Run the target with --skip-nx-cache and compare the fresh output to the cached artifact. If the two differ while the hash is the same, something the task reads is missing from its inputs.

Developer machines and CI usually disagree on a few variables, and each disagreement fails in one of two ways:

  • Declared but different. CI=true on runners or a different NODE_ENV changes the hash. The task is correct, but local and CI runs stop sharing remote cache entries.
  • Undeclared but read. Locale (LANG, LC_ALL) or TZ can change sorting, date formatting or generated timestamps. The hash stays the same, so one environment replays output that the other would not have produced.

Fix both the same way. Pin the values inside the task command, for example by setting TZ=UTC and a fixed locale, so they cannot vary. If a variable must vary, declare it as an env input. Then run the target locally and in CI, and confirm the hashes match before you rely on cross-environment cache hits.

Impact on cost and engineering time

The hash checks from the previous section have a cost-and-time side as well, and a scoping mistake shows it clearly.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 projects, an average build of 3 minutes, 20 commits per day, and a typical commit that genuinely changes 5 projects. Assume a shared targetDefaults build input includes GIT_SHA, but only 2 projects embed it.

Formula: build minutes per commit = (projects that always miss + projects genuinely changed) × average build minutes

State Projects rebuilt per commit Cross-commit hit rate Build minutes per commit Build minutes per day
GIT_SHA in shared defaults 40 0% 40 × 3 = 120 2,400
GIT_SHA scoped to 2 projects 2 + 5 = 7 33/40 ≈ 82% 7 × 3 = 21 420

Before the fix, a new SHA changes every hash, so a commit that only touches documentation still rebuilds all 40 projects. After the fix, only the 2 projects that embed the SHA miss on every commit. The other 38 miss only when their real inputs change.

CI minutes are only part of the saving. Engineers also stop waiting on full rebuilds and stop asking why the cache "never works."

The maintenance cost is ongoing. Each new variable needs someone to decide whether it belongs in a project's inputs. That review is cheap compared with under-declaring. If a variable that changes output is missing from the hash, Nx can return a stale artifact. That shows up later as a production build with the wrong API endpoint, or as tests that pass locally and fail in CI. Those failures cost far more to trace than the review that would have prevented them.

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 inputs 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.