How does Nx compute a task hash, and why do .env files break it?
Before Nx runs a cacheable target, it computes a hash from everything it has been told can affect the output. If that hash matches an earlier run, stored locally or in a remote cache, Nx replays the saved terminal output and restores the artifacts instead of running the task.
The hash is built from:
- Declared file inputs. These are filesets such as the project's source files, often with tests and docs excluded, plus workspace-level config files.
- Dependency inputs. Files from projects this one depends on, usually expressed with the
^prefix, so a change in a library invalidates the apps that consume it. - External dependency versions. These are the resolved versions of npm packages the project uses.
- Env and runtime inputs. These are named environment variables, and the output of commands such as a Node version check.
- The command and its options. This covers the executor or command, configuration, and CLI flags.
The core rule follows from this. Anything a task reads but does not declare is invisible to the cache. Nx does not trace which files or variables your build process actually touches. It hashes only what the inputs describe.
.env files are a common way to break this rule, and they fail in two directions:
- Under-declaring. Your bundler inlines
API_URLat build time, butAPI_URLis not an input. A staging build populates the cache. A production build later produces the same hash, so Nx restores an artifact that points at staging. The task reports success, and nothing indicates the result is wrong. - Over-declaring. You add the entire
.envfile as an input to be safe. Now editing an unrelated variable, such as a local debug flag, invalidates every build that uses it, and the cache hit rate drops.
+---------------------------+
| Declared file inputs |--+
+---------------------------+ |
| Dependency inputs (^) |--+
+---------------------------+ |
| External dep versions |--+ +-------------+ +---------------+
+---------------------------+ +---->| Task hash |---->| Cache lookup |
| Env + runtime inputs |--+ +-------------+ | local/remote |
+---------------------------+ | +---------------+
| Command + options |--+ | |
+---------------------------+ | | hit | miss
| Dependency task hashes |--+ v v
+---------------------------+ +-----------+ +-----------+
| Restore | | Run task, |
| output + | | store |
| artifacts | | result |
+-----------+ +-----------+
+---------------------------+
| Undeclared read | NOT in hash: build inlines API_URL, so
| (API_URL from .env) | staging and prod builds hash the same and
+---------------------------+ staging artifact is restored (under-declared)
+---------------------------+
| Whole .env as input |--> any edit (e.g. a DEBUG flag) changes the
| (every variable hashed) | hash; dependent builds miss (over-declared)
+---------------------------+
Nx hashes only the inputs it is told about, so an undeclared .env variable serves stale artifacts while declaring the whole .env file causes needless cache misses.
Which environment variables actually belong in a target's inputs?
The test for any variable is whether changing it changes the bytes the target produces. If it does, it belongs in that target's inputs. If it doesn't, hashing it only costs you cache hits.
Build-time values get baked into the output. A bundler that inlines process.env.API_URL writes the literal string into the JavaScript it emits. A codegen step that reads a feature flag decides which files to generate. Restoring a cached artifact built with a different value ships the wrong URL or the wrong code paths. These must be hashed.
Runtime values are read only when the artifact executes. A Node service that reads DATABASE_URL from process.env at startup compiles to the same output whatever the variable holds. Hashing it for build gives you nothing, and every developer with a different local database gets their own cache miss.
Targets differ. test often depends on values that change assertions or fixtures, such as TZ or a mode flag, even when build ignores them. lint usually depends on none, unless your lint config branches on an environment variable.
| Variable type | build | test | lint | serve |
|---|---|---|---|---|
| Bundler-inlined API URL | Hash | Hash if tests import the bundle | No | Not cached |
| Codegen feature flag | Hash | Hash | Hash only if generated code is linted | Not cached |
NODE_ENV / build mode |
Hash | Hash | Usually no | Not cached |
| Runtime secret (DB URL, API key) | No | No (inject at run time) | No | Not cached |
TZ, locale |
No | Hash if assertions depend on it | No | Not cached |
| Local debug or log level | No | No | No | Not cached |
If you own build targets or CI pipelines, maintain this classification. A single misfiled row causes either stale artifacts or a cache that rarely hits.
How do you declare env, fileset, and runtime inputs in Nx?
Nx accepts several input types in a target's inputs array, and each one answers a different question about what the output depends on.
Env inputs: hash one variable, not a file
An entry of the form { "env": "API_BASE_URL" } adds the current value of that single variable to the hash. Use this when your classification says a specific variable changes build output. It is preferred over hashing a whole .env file because it is precise. If a teammate edits an unrelated LOG_LEVEL line, the hash stays the same and the cache keeps hitting. It also captures values injected by CI, which never appear in any file.
Fileset inputs: hash the file itself
Sometimes the file is the real dependency, for example when a bundler inlines everything it finds there. For a shared root file, use { "fileset": "{workspaceRoot}/.env" }. For a per-project file, use { "fileset": "{projectRoot}/.env" }. A root-level .env is not covered by a default input that matches all project files, because that pattern is scoped to {projectRoot}. Editing the root file will not invalidate any cache entry unless you list it explicitly.
Runtime inputs: hash command output
{ "runtime": "node --version" } runs the command and hashes what it prints. This is useful for toolchain versions that affect output. The command runs on every hash computation, so keep it fast and deterministic. A slow command makes every task slower, and output that includes a timestamp means the cache never hits.
Input syntax has changed across releases. Confirm the exact form against the documentation for the Nx version you have installed.
// apps/web/project.json (Nx parses project.json with comments allowed)
{
"name": "web",
"targets": {
"build": {
"inputs": [
// All files under {projectRoot}. This does NOT include a root-level .env.
"default",
// Env input: hash only the variable that changes build output.
// An edit to an unrelated LOG_LEVEL line keeps the cache hitting.
// CI-injected values are captured too.
{ "env": "API_BASE_URL" },
// Fileset input: the shared root .env must be listed explicitly,
// or edits to it will not invalidate any cache entry.
{ "fileset": "{workspaceRoot}/.env" },
// Fileset input: a per-project .env. It already falls under "default",
// but listing it states the dependency.
{ "fileset": "{projectRoot}/.env" },
// Runtime input: hash the command's output. It runs on every hash
// computation, so it must be fast and must not print timestamps.
{ "runtime": "node --version" }
]
}
}
}
A build target that hashes one env variable, a shared root .env and a per-project .env as filesets, and the Node version as a runtime input. Confirm the syntax against the docs for your installed Nx version.
Centralizing env inputs with namedInputs and targetDefaults
Declaring env inputs per project works, but it breaks down at scale. One project.json lists API_URL, another forgets it, and the two projects now cache differently for the same reason. The fix is to define the env inputs once in nx.json as a named input and reference that name wherever it applies.
In the accompanying example, namedInputs holds three entries. default covers everything under {projectRoot}. production starts from default and excludes test files and spec configs. sharedEnv is a list of env inputs for the variables that change build or test output, such as API_URL and FEATURE_FLAGS_MODE. Under targetDefaults, build uses production, ^production and sharedEnv, while test uses default, ^production and sharedEnv. lint uses only default, because a linter's result depends on source and config, not on which API endpoint a deploy targets. If you added sharedEnv to lint, every env change would rerun lint across the workspace for no benefit.
This setup gives you one place to answer "which variables affect builds?" New projects inherit the correct behavior without copying anything, and a project that truly needs extra inputs can still override the default in its own configuration.
The cost is the reverse of that leverage. Adding a single variable to sharedEnv, or touching nx.json at all, can change the hash for every build and test target in the workspace. That means a full cold run in CI. Treat edits to this file like any other build-system change: give them a reviewer who understands the cache impact, and merge them deliberately instead of bundling them into unrelated pull requests.
{
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s"
],
"sharedEnv": [
{ "env": "API_URL" },
{ "env": "FEATURE_FLAGS_MODE" }
]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production", "sharedEnv"]
},
"test": {
"cache": true,
"inputs": ["default", "^production", "sharedEnv"]
},
"lint": {
"cache": true,
"inputs": ["default"]
}
}
}
An nx.json that defines a reusable sharedEnv named input next to default and production, applies it to build and test through targetDefaults, and leaves lint without env inputs so env changes do not rerun lint across the workspace.
Does Nx loading a .env file mean it is hashed?
No. Two separate mechanisms are involved, and it helps to keep them apart. Nx can read .env files from the workspace or project directory and inject their values into the environment of the process it spawns for a task. That happens before your code runs, and it is independent of whether your application also calls dotenv at startup. Hashing is a different step. Nx builds the task hash only from the inputs you declared. A variable can sit in the task's environment and still have no effect on the hash, because nothing told Nx to include it.
The .env file itself may already be a file input if a broad fileset matches it. That does not cover values that come from the shell or from CI secrets.
Which files Nx loads, and the order in which later files override earlier ones, has changed between releases. Check the documentation for your Nx version instead of assuming a particular precedence.
The usual failure shows up across environments. A developer has API_BASE_URL in a local .env, while CI sets it through pipeline variables, or leaves it unset. If the variable is not an env input, both runs produce the same hash. With remote caching, CI can then restore an artifact that was built with a local value, which is a wrong hit. The opposite failure happens when the variable is declared but its value differs between environments. The hashes never match, and every machine misses.
The fix is to declare the env inputs that matter and make sure those values resolve identically in both places. Values that differ between environments should then produce different hashes, as they should.
How can you verify that Nx cache inputs are correct?
Declaring inputs is only half the job. The other half is proving the hash reacts to the right things. These checks need nothing beyond the Nx CLI and take a few minutes per target.
Verification checklist
- Inspect the resolved configuration. Run
nx show project <name>and read the target's resolvedinputs. Confirm that named inputs fromtargetDefaultsexpanded as intended, that everyenventry is present, and that no project-level override silently replaced the defaults. - Change one declared variable. Run the target, change a single variable listed in its
envinputs, and run it again. You should get a cache miss. A hit means the variable is not reaching the hash, usually because of a typo in its name or a shadowed input definition. - Change an unrelated variable. Set a variable the target does not declare and run again. You should get a cache hit. A miss means something broader is being hashed, such as a runtime input that echoes the whole environment or a fileset that includes the
.envfile. - Repeat in CI. Run steps 2 and 3 in a pipeline job, ideally against the same remote cache your developers use. This catches values that resolve differently on runners than on laptops.
- Gate changes. In code review, require this check whenever someone edits
inputs,namedInputs, or adds a new environment variable read by a cached target.
The payoff is that a stale-artifact bug stops being a hunt. Without this check, engineers often spend hours bisecting deploys and clearing caches before anyone looks at the hash. With it, a wrong input shows up as a failed assertion before merge.
#!/usr/bin/env bash
# verify-nx-inputs.sh: assert that a target's cache hash reacts to the right env vars.
# Usage: ./verify-nx-inputs.sh web build
set -euo pipefail
PROJECT="${1:-web}"
TARGET="${2:-build}"
LOG="$(mktemp)"
# Step 1: print the resolved inputs so a reviewer can read them.
npx nx show project "$PROJECT" --json | jq ".targets[\"$TARGET\"].inputs"
run_target() { npx nx run "$PROJECT:$TARGET" > "$LOG" 2>&1; }
# Nx reports cache reuse in its run summary. Check the exact wording
# against your Nx version before relying on this grep.
from_cache() { grep -q "from the cache" "$LOG"; }
# Baseline run. This populates the cache for these values.
API_URL= UNRELATED_VAR=one run_target
# Step 2: change a DECLARED variable. Expect a cache miss.
API_URL= UNRELATED_VAR=one run_target
if from_cache; then
echo "FAIL: changing API_URL did not invalidate the cache"; exit 1
fi
# Step 3: keep API_URL the same as the previous run and change an
# UNDECLARED variable. Expect a cache hit.
API_URL= UNRELATED_VAR=two run_target
if ! from_cache; then
echo "FAIL: UNRELATED_VAR changed the hash; inputs are too broad"; exit 1
fi
echo "OK: $PROJECT:$TARGET inputs react to API_URL only"
A CI-ready script that prints a target's resolved inputs with nx show project, then asserts a cache miss when a declared env variable changes and a cache hit when an undeclared one changes.
When should you avoid caching env-dependent targets, and what are the alternatives?
Some values should not reach the hash at all, and some targets should not be cached.
Secrets inlined into build output. If a build bakes an API key into a bundle, the cached artifact contains that key. With a shared or remote cache, anyone who can read the cache can pull the artifact and extract the secret, including people who were never given the key. Declaring the secret as an input does not help. Correct invalidation just stores another copy for every rotated value. Keep secrets out of build artifacts and read them at run time.
Volatile values. Timestamps, commit SHAs and CI build IDs change on every run, so a target that hashes them never hits the cache. Stamp them in a small uncached step after the cached build. If you do need them inside the build, accept the misses deliberately and note why. Runtime inputs whose output varies, such as unsorted listings or version strings with build metadata, cause the same misses. These are harder to diagnose because they look random.
| Approach | Safety | Hit rate | Maintenance burden | CI cost effect |
|---|---|---|---|---|
Hash whole .env file |
Secrets in file affect hash; artifacts may still embed them | Low: any edit invalidates | Low | More rebuilds |
Per-variable env inputs |
Good if secrets excluded | High | Medium: list must track code | Lowest, until it drifts |
| Runtime inputs | Depends on command | Varies with determinism | Medium | Unpredictable if non-deterministic |
cache: false on target |
Nothing cached, nothing leaked | None | Low | Full cost every run |
| Move config to run time | Best: artifacts carry no env | High | Medium, one-time refactor | Lowest long-term |
Per-variable inputs carry a hidden risk. When code starts reading a new variable and nobody adds it to the inputs, nothing fails. Nx keeps serving stale artifacts until someone notices wrong behaviour in a deploy.
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 cache env variables 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.