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.
- Search for explicit reads. Grep the project's source and its build config for
process.envandimport.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. - 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_URLandFEATURE_NEW_CHECKOUT, plusNODE_ENV, which also switches minification and dead-code elimination. - Check codegen. A pre-build script that generates a typed config module from
APP_REGIONbelongs in the hash even though no source file reads it directly. - Read tool docs for implicit reads. Many tools read
NODE_ENVwithout being asked.TZand locale variables such asLANGcan 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
.envby 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.