How does Nx compute a task hash, and why are undeclared env variables invisible?

An Nx task hash is a fingerprint of everything Nx has been told can affect a task's result. Before running a target, Nx collects the declared inputs and combines them with the target's command and options into a single hash. These inputs include file sets, named inputs, dependency outputs, external dependency versions, and env and runtime entries. It then looks that hash up in the local cache and, if configured, the remote cache. On a hit, Nx restores the stored output files and terminal output instead of executing anything.

  file sets / namedInputs ──┐
  dependency outputs ───────┤
  external dependencies ────┤
  declared env / runtime ───┼──► [ task hash ] ──► cache lookup ──► hit: restore outputs
  command + options ────────┘                                  └─► miss: run, store

  API_URL (undeclared) ─────────────────────────────────────────► read by the task only
                                    (never reaches the hash)

The hash covers only what is declared. Nx does not trace which environment variables a build script, bundler or test runner reads at execution time. If your build inlines API_URL into a bundle and API_URL is not an input, two runs with different values produce the same hash. The second run is a cache hit, and it serves an artifact built for the wrong environment. Nx raises no error and logs no warning, because from its point of view nothing changed.

This is why input modeling is a contract you audit, not a setting you tune once. Every new variable a tool starts reading is a possible false cache hit until someone declares it. The same goes for every config file that quietly references process.env.

 DECLARED INPUTS
 +----------------------------+
 | file sets / namedInputs    |--+
 | dependency outputs         |--+
 | external dependencies      |--+
 | env / runtime entries      |--+
 | command + options          |--+
 +----------------------------+  |
                                 v
                        +-----------------+
                        |    task hash    |
                        +-----------------+
                                 |
                                 v
                        +-----------------+
                        |  cache lookup   |
                        | (local, remote) |
                        +-----------------+
                           |           |
                       hit |           | miss
                           v           v
             +------------------+  +----------------+
             | restore outputs  |  | execute task   |<--+
             | + terminal log   |  | store outputs  |   |
             +------------------+  +----------------+   |
                                                        |
 +----------------------------+                         |
 | API_URL (undeclared)       |-------------------------+
 +----------------------------+  read at run time only;
                                 never reaches the hash

Only declared inputs and the command feed the task hash, so an undeclared variable like API_URL reaches the task only when it actually executes; a cache hit restores the stored outputs regardless of its value.

What are env and runtime inputs in Nx, and when should you use each?

Nx gives you two input forms for state that lives outside the file tree.

An env input is an object with an env key, such as { "env": "API_URL" }. When Nx computes the hash, it reads the variable's current value from its own process environment and folds that value in. If the value changes, the hash changes. If the value stays the same, the cache can be reused. Only the name you list is read. Nx does not scan for other variables.

A runtime input is an object with a runtime key that holds a shell command, such as { "runtime": "node --version" }. Nx runs the command and hashes its output. Typical uses are the Node version, the OS and CPU architecture (uname -sm), and toolchain versions like rustc --version or go version. These matter whenever native addons, generated binaries or compiler behaviour depend on the machine.

Both forms can appear in three places: directly in a target's inputs array, inside a namedInput that several targets reference, or in targetDefaults so every target with that name inherits them. Choosing among these is covered in section 4.

Runtime commands execute on every hash computation, including the ones that end in a cache hit. A command that takes half a second, run once per task in a large affected graph, adds that delay to every run. The command must also be deterministic. Output containing timestamps, PIDs or network responses changes on every run, so the cache never hits.

Env input Runtime input
What is hashed Current value of a named variable Output of a shell command
Cost Negligible (a lookup) One process spawn per hash computation
Determinism risks Variable differs between local and CI shells Noisy output, slow or network-dependent commands
Typical use NODE_ENV, public API base URLs, feature flags read at build time Node/OS/arch, compiler or toolchain versions
{
  "namedInputs": {
    "sharedGlobals": [
      { "runtime": "node --version" },
      { "runtime": "uname -sm" }
    ],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": [
        "production",
        "^production",
        { "env": "NODE_ENV" }
      ]
    },
    "e2e": {
      "cache": true,
      "inputs": [
        "default",
        "^production",
        { "env": "API_URL" }
      ]
    }
  }
}

An nx.json where a sharedGlobals named input hashes the Node version and OS/architecture through fast, deterministic runtime commands for every target that uses "default", while targetDefaults add env inputs so build hashes change with NODE_ENV and e2e hashes change with API_URL.

Which tasks are env-sensitive? A worked audit of one build target

Any target whose output changes when a variable changes is env-sensitive: bundlers that inline values, code generators, and test runners whose snapshots depend on locale or time zone. Take a hypothetical web:build target and audit it in four passes.

  1. Search for explicit reads. Grep the project's source and its build config for process.env and import.meta.env. Config files matter as much as application code, because a bundler config that branches on a variable changes the output for every file it processes.
  2. Separate baked-in values from runtime reads. A read inside a request handler of a server that runs after build doesn't affect the artifact. A read that the bundler replaces with a literal does. Here that means API_BASE_URL and FEATURE_NEW_CHECKOUT, plus NODE_ENV, which also switches minification and dead-code elimination.
  3. Check codegen. A pre-build script that generates a typed config module from APP_REGION belongs in the hash even though no source file reads it directly.
  4. Read tool docs for implicit reads. Many tools read NODE_ENV without being asked. TZ and locale variables such as LANG can change date formatting in generated output. Some tools also honour their own prefixed config variables.

Step 4 is where audits fail. Implicit third-party reads don't show up in a grep, and a dependency upgrade can add one. Treat the audit as a best effort, and keep running periodic comparisons between a cached build and a fresh, uncached build.

The resulting inputs for web:build are four { "env": ... } entries: NODE_ENV, API_BASE_URL, FEATURE_NEW_CHECKOUT and APP_REGION. Add a { "runtime": "node --version" } entry alongside them.

{
  "name": "web",
  "targets": {
    "build": {
      "command": "node scripts/gen-config.mjs && vite build",
      "outputs": ["{workspaceRoot}/dist/apps/web"],
      "inputs": [
        "default",
        "^default",
        { "env": "NODE_ENV" },
        { "env": "API_BASE_URL" },
        { "env": "FEATURE_NEW_CHECKOUT" },
        { "env": "APP_REGION" },
        { "runtime": "node --version" }
      ]
    }
  }
}

The audited web:build target in project.json: its file inputs plus the four env variables found by the audit and the Node version, all of which feed into the cache hash.

Where should env inputs be declared: namedInputs, targets or targetDefaults?

With the four variables identified, the next decision is where to put them. You can declare them inline on web:build, in a named input, or in targetDefaults in nx.json. These options differ mainly in how far a change to one variable spreads.

The default choice should be a named input, for example checkoutEnv, that holds those four env entries. Reference it only from the targets that read them, which here are web:build and possibly web:serve. The name documents why the variables matter, and adding a fifth variable means editing one place.

Avoid putting them in targetDefaults for build or in the default named input. This is convenient, but it attaches APP_REGION to every project's build. A library that never reads it will still get a new hash whenever a CI job changes regions, so one app's configuration ends up invalidating cache entries across the workspace.

You also need to understand propagation. An input listed without a prefix affects the hash of the project's own task. The ^ prefix, as in ^checkoutEnv, asks Nx to resolve that named input from the project's dependencies. Whether env entries carry through that way, and how this interacts with dependsOn, is something to confirm in your own workspace before relying on it. Run the hash inspection from the next section on a dependent project and look for the variable explicitly.

Placement Scope Coupling risk Maintenance effort
Inline on one target Single target Low Medium: duplicated when several targets need the same set
Named input, referenced selectively Targets that opt in Low Low: one definition, explicit consumers
targetDefaults / default named input Every matching target in the workspace High Low to edit, high to debug when caches miss
// nx.json: define the env set once, without attaching it to every target
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    // Only targets that reference checkoutEnv hash these variables
    "checkoutEnv": [
      { "env": "APP_REGION" },
      { "env": "CHECKOUT_API_URL" },
      { "env": "PAYMENT_PROVIDER" },
      { "env": "FEATURE_FLAGS_KEY" }
    ]
  }
}

// apps/web/project.json: opt in only on the targets that read the variables
{
  "name": "web",
  "targets": {
    "build": {
      // Own task hash includes checkoutEnv; ^production pulls dependency files.
      // Before relying on ^checkoutEnv for dependents, inspect the hash.
      "inputs": ["production", "^production", "checkoutEnv"]
    },
    "serve": {
      "inputs": ["default", "checkoutEnv"]
    }
  }
}

A checkoutEnv named input defined once in nx.json and referenced only by web:build and web:serve, so changing APP_REGION does not invalidate unrelated projects' caches.

How do you verify what Nx actually hashes?

A declared input is a claim, not proof. Verification has three steps, each catching a different failure.

Inspect the resolved inputs

Run nx show project <project> --json and read the inputs array for the target. This is the configuration after targetDefaults, named inputs and project-level overrides have merged, so it shows what Nx will hash rather than what you meant to write. If the { "env": "API_BASE_URL" } entry is missing, the merge dropped it. A common cause is a project-level inputs array that replaced the default instead of extending it.

Run a controlled experiment

Run the target once to populate the cache, then rerun it with only the env var changed. You should see a cache miss. Then revert the variable and run again. That should be a cache hit, because the original hash still exists. A hit after the change means the variable is not part of the hash. A miss after reverting means something else in the environment varies between runs and is leaking into the hash.

Compare against a fresh build

The experiment only tests variables you already know about. To find the ones the audit missed, restore the cached artifact, then run with --skip-nx-cache and diff the two output directories. If they differ while the hash is the same, the task reads an input Nx does not track.

Schedule this diff as a nightly CI job over your env-sensitive targets. A false hit found here costs one job's runtime. The same false hit found in production starts with a stale config value, followed by a long search to explain why a "fresh" build behaved like an old one.

When should you avoid declaring env inputs? Over-declaring, remote caches and secrets

Under-declaring is the dangerous direction. A missing env input produces an artifact that looks valid, and a remote cache then serves it to every machine that computes the same hash. A bundle built against a staging API URL can be restored straight into a production deploy. Over-declaring fails safely, but each extra input costs cache hits.

Declared input Effect on the hash Better approach
CI build number, timestamp Changes every run, so the hit rate approaches zero Inject after the build, or leave it out of the artifact
Per-runner path (workspace dir, home) Entries are never shared across runners Normalize the path or omit it
Runtime command printing dates, unordered listings or machine paths Hash differs silently, so caching stops without any error Make the output deterministic (sorted, stripped) or drop the command
Raw secret Low-entropy values can be guessed from the hash, and the config documents which secrets affect which outputs Hash a non-secret discriminator such as a deployment-target name
Deployment target name One entry per real variant Declare it

Illustrative estimate, not measured: declaring 4 environments × 3 Node versions splits one artifact into up to 12 cache entries. Assume a 6-minute build. In the worst case, every combination pays one cold build, which is 72 minutes of compute before the cache serves all variants. Before declaring the Node version, check whether it actually changes the output.

Treat local and CI differences as a deliberate choice. If developers read from the CI remote cache, align the declared values so that hits reflect identical outputs. Where outputs really differ, distinguish them explicitly. Debug and production builds usually differ in minification and source maps. They are better modeled as separate configurations than as one target that shares entries.

What are the alternatives to hashing env variables, and how do you keep the contract from drifting?

Hashing an env variable is only one option, and often not the best. The goal is an artifact whose content is fully determined by the hash. You can get there by keeping environment-specific values out of the artifact entirely.

Approach Correctness Cost
Inject config and secrets at runtime or deploy time High: the artifact is environment-neutral, so one cache entry is valid everywhere Requires runtime config loading in the app
Single discriminator variable (e.g. a target-environment name) High if every environment-specific value maps from it One extra cache entry per environment
Separate targets or configurations per environment High and explicit; visible in the project graph More configuration to maintain
Disable caching for the task Always correct Every run pays full execution time

Disabling caching is honest, but use it only for tasks you cannot model safely, such as those that read clocks or call external services.

Keeping the contract from drifting

Nx raises no error when code reads a variable that no input declares. Cache correctness degrades quietly, and the symptom shows up weeks later as a stale artifact. Add these checks to PR review:

  • A new variable read comes with a matching input declaration in the same change set.
  • A removed read also removes its input.
  • Volatile values, such as timestamps or commit SHAs, are never hashed.
  • Secrets are represented by a discriminator, never hashed directly.
  • Any env-touching change includes the change-one-variable verification run.

This takes a few minutes per PR. Skipping it costs more: hours spent bisecting a wrong deploy, and a team that learns to distrust the cache and bypass it.

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 inputs, 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.