How Nx decides what to run: the project graph and task graph

Nx CI optimization rests on one data structure: the project graph. Nx finds projects from project.json files and from package.json files listed in your package manager's workspaces configuration. It then analyzes source imports, installed dependencies and TypeScript configuration to infer the edges between them. When you run a command, Nx derives a task graph from it. Each node is a target on a project, such as myapp:build, and each edge is a task dependency (Nx mental model).

Two separate optimizations read this graph:

  • Affected detection maps changed files to projects, then follows dependency edges to find which projects to run tasks for.
  • Caching hashes each task's inputs. On a hit, Nx restores the outputs instead of running the task.

changed files → project graph → task graph → cache lookup → execute (miss) or restore (hit)

If the graph is missing an edge, both optimizations fail in the same way: they skip work that should have run.

Inspecting what Nx found

nx show project <name> lists every task Nx detected for a project and shows where each setting came from. Nx merges configuration in this order, with later sources winning:

  1. Settings inferred by plugins
  2. targetDefaults in nx.json
  3. The project's own package.json or project.json

Dependencies Nx cannot see

Source analysis only follows code. It does not see shared shell scripts, root-level config files or non-TypeScript assets that a build reads. Declare these yourself. Use implicitDependencies for project-level edges, and add file inputs such as {workspaceRoot}/scripts/** for cache hashing. If you skip this step, a change to one of these files will neither mark projects as affected nor invalidate their cache.

# Root-level shell scripts and the base tsconfig are not visible to source analysis.
# Declare them as inputs so that changing them invalidates the cache hash for build tasks.
cat > nx.json <<'EOF'
{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/scripts/**", "{workspaceRoot}/tsconfig.base.json"],
    "default": ["{projectRoot}/**/*", "sharedGlobals"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["default", "^default"],
      "cache": true
    }
  }
}
EOF

# myapp reads files from shared-assets at build time but never imports it.
# Add the edge by hand so affected detection follows it.
cat > apps/myapp/project.json <<'EOF'
{
  "name": "myapp",
  "implicitDependencies": ["shared-assets"]
}
EOF

# Inspect the merged result: plugin inference, then targetDefaults, then project.json.
nx show project myapp --json

# A change in scripts/ or shared-assets now marks myapp as affected.
nx affected -t build --base=origin/main

Declares root scripts as cache inputs and adds an implicit project dependency, then inspects the merged configuration and runs affected builds.

Running only what changed with nx affected

nx affected -t build test lint runs a git diff between a base and a head commit. Nx maps each changed file to the project that owns it, then walks the project graph to add every project that depends on those projects. If a shared library changes, every app that imports it is included. A project that has no path to a changed file is skipped. Nx uses the merge base of --base and --head for the diff, so commits that landed on main after the branch was created are not included.

Choosing the base in CI

For pull requests, set the base to the target branch, usually origin/main. The diff then covers what the PR introduces.

For builds on main, use the last commit on main whose pipeline succeeded, not HEAD~1. If an earlier main build failed or was cancelled, HEAD~1 drops the commits that build never validated. Your CI system has to record the last green SHA or query it, then pass it as --base or through the NX_BASE environment variable.

History depth

Many CI checkouts are shallow clones with depth 1 by default. With a shallow clone, the base commit or the merge base may not exist locally, and the diff fails or produces a wrong result. Fetch full history, or at least enough history to reach the merge base, and fetch the base branch explicitly.

Failure modes

  • Force-pushed branches: the recorded base SHA may no longer be in the branch history.
  • First build on a new branch: no previous successful run exists. Fall back to main or to running everything.
  • Running everything: an unresolvable base can turn every build into a full build without any error.
  • Skipping projects that should have run: a base that is too recent hides real changes. This is the most dangerous case because the pipeline still passes.
#!/usr/bin/env bash
# Pick a safe base for `nx affected` in CI, falling back to a full run when it can't.
set -euo pipefail

# Set these from your CI system (placeholder names).
EVENT="${CI_EVENT:-push}"            # "pull_request" or "push"
LAST_GREEN_SHA="${LAST_GREEN_SHA:-}"  # last main commit whose pipeline succeeded

# Shallow clones (depth 1) can't reach the merge base, so fetch full history.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --no-tags --unshallow origin
fi
# Fetch main explicitly so origin/main exists locally.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main

if [ "$EVENT" = "pull_request" ]; then
  # PR: diff against the target branch. Nx uses the merge base.
  BASE="origin/main"
else
  # Main: diff against the last green commit, not HEAD~1.
  BASE="$LAST_GREEN_SHA"
fi

# Guard against a missing base (first build) or a base that is no longer in
# history (force push). Without this guard you could silently run everything
# or skip projects that changed.
if [ -z "$BASE" ] \
  || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null \
  || ! git merge-base --is-ancestor "$BASE" HEAD; then
  echo "Base '${BASE:-<none>}' is not usable; running all projects." >&2
  npx nx run-many -t build test lint
  exit 0
fi

echo "Running affected projects from $BASE to $(git rev-parse HEAD)"
npx nx affected -t build test lint --base="$BASE" --head=HEAD

A CI script that uses origin/main as the base for pull requests and the last green SHA for main builds, removes the shallow clone, and runs every project with a logged warning when the base is missing or no longer in the branch history.

Configuring the Nx cache: cacheable targets, inputs and outputs

Caching is opt-in per target. In nx.json, add "cache": true under targetDefaults for build and test. Only do this for tasks that are side-effect free, meaning the same inputs always produce the same outputs. An e2e suite that calls a live backend fails this test, because the backend can change the result while the hash stays the same.

Inputs: what goes into the hash

Inputs determine the hash that Nx compares against stored results. Define them globally in nx.json, either as namedInputs or in targetDefaults. Then override them for an individual project in its project.json target, because project configuration takes precedence over targetDefaults. Exclude files that cannot affect the output with a negated pattern such as !{projectRoot}/**/*.md, so editing a README does not invalidate the build. Add { "env": "API_URL" } for any environment variable that is baked into the artifact.

Outputs: what gets restored

Declare where files land, for example {workspaceRoot}/dist/{projectName}. If you leave this out, a cache hit replays the terminal output but restores no files. The log shows a successful build, and then the next step (Docker packaging, deploy) fails because dist is empty.

Input mistake Symptom Consequence
Too broad (docs, unrelated config) Cache rarely hits Slow CI, no errors
Too narrow (missing env var or config file) Hits when it shouldn't Stale artifact ships

A cache that is too broad only costs you time. A cache that is too narrow is a correctness bug, because CI stays green while it ships the wrong build. See the Nx caching docs.

{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/**/*.spec.ts"
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production", { "env": "API_URL" }],
      "outputs": ["{workspaceRoot}/dist/{projectName}"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^production"],
      "outputs": ["{workspaceRoot}/coverage/{projectName}"]
    }
  }
}

An nx.json that turns on caching for build and test, keeps Markdown and spec files out of the build hash, adds API_URL to the hash, and declares dist and coverage outputs so a cache hit restores the files; an individual project can override any of these in the matching target of its project.json.

Local cache vs. remote cache on ephemeral CI runners

On an ephemeral runner, every job starts from a fresh VM or container. The workspace is cloned, dependencies are installed, and the local cache directory is empty, because nothing from the previous job survived. Nx computes the hash for each task, finds no matching entry, and runs everything. The cache configuration from the previous section only pays off in CI when results are stored somewhere that outlives the runner and that every machine can reach. That storage is a remote cache.

Nx's managed option is Nx Cloud, which you connect with npx nx@latest connect (source). Once connected, a cache hit restores both the terminal output and the output files, so downstream steps behave as if the task had run.

A shared cache introduces a security surface. Any job that can write an entry can influence what other jobs restore. If pull requests from forks run with a write-capable token, an outside contributor could upload a poisoned artifact under a hash that a trusted branch later reuses. Give write access only to trusted pipelines. Keep the access token in your CI provider's secret store, and keep it out of fork-triggered jobs.

Transfer also has a cost. Restoring means a network download, and a miss followed by an upload adds more time. For a lint task that finishes in two seconds, or a build that produces hundreds of megabytes, that overhead can approach or exceed the cost of running the task again. For tasks like that, consider leaving caching off.

Local cache Managed remote cache No caching
Survives between CI runs No (ephemeral runner) Yes N/A
Shared across machines No Yes N/A
Security surface Single machine Write access, token handling None
Overhead per task Disk I/O Network download/upload Full execution
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  # Use pull_request, not pull_request_target. For PRs from forks,
  # GitHub does not pass repository secrets, so fork jobs run without
  # the Nx Cloud token and cannot write to the shared cache.
  pull_request:

permissions:
  contents: read

jobs:
  main:
    runs-on: ubuntu-latest  # ephemeral: local cache starts empty every run
    env:
      # Token comes from the CI secret store and is never hardcoded.
      # Give trusted branches a read-write token. If forks need cache
      # hits, give them a read-only token so they can restore but not upload.
      NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # full history so nx affected can diff against main

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      # Hashes are computed locally. Matching entries are restored from
      # the remote cache, and misses run and are uploaded if the token can write.
      - run: npx nx affected -t lint test build --base=origin/main

A GitHub Actions workflow that reads the Nx Cloud token from the CI secret store, so jobs on ephemeral runners can share the remote cache while fork-triggered pull requests run without write access.

A worked CI pipeline combining affected and caching

The pull request job does three things in order. It checks out enough git history for Nx to compare commits, because a depth-1 shallow clone has no merge base and Nx cannot work out what changed. It sets the base to main and the head to the PR commit, using --base/--head or the NX_BASE/NX_HEAD environment variables. It then runs nx affected -t lint test build. Affected detection prunes the task graph down to the changed projects and their dependents. The remote cache then skips any remaining task whose hash already exists, for example after a rebase that didn't touch a library, or on a re-run of a flaky job.

The main-branch job uses a different base. Comparing against the previous commit is fragile: if the last pipeline failed, its changes were never verified, and diffing against HEAD~1 would skip them. Using the last successful commit on main as the base makes every push retry everything since the last green build.

Illustrative estimate (assumed inputs, not measured):

  • 40 projects, each with lint, test and build, for 120 tasks in total.
  • A 2-minute average per task.
  • 25% of projects affected by a typical PR, which gives 30 tasks.
  • A 50% cache hit rate on those affected tasks.

Without Nx, a full run costs 240 runner-minutes. With affected detection, 30 tasks run, which is 60 minutes. With caching on top, about 15 tasks run, so roughly 30 minutes plus cache download time. Wall-clock savings depend on how many tasks run in parallel. Compute cost drops roughly in line with the number of tasks executed.

Verifying that affected detection and caching actually work

Before trusting those numbers, confirm each mechanism behaves as you expect. A misconfigured input fails silently: either the cache never hits, or it hits when it shouldn't and ships stale output.

Confirm cache hits

Run a cacheable target, such as nx run-many -t build, then run the exact same command again without changing anything. In the Nx output, tasks restored from cache are marked as such, and the closing summary reports how many tasks were read from cache. If the second run executes anything, some input is changing between runs. Common causes are a timestamp, a generated file, or an undeclared output being written into a hashed directory.

Confirm what does and does not invalidate

Edit a file you excluded from inputs, such as README.md, and rerun. You should still get a full cache hit. Then change something you declared as an input, such as an environment variable or a config file like tsconfig.base.json. The affected tasks should now execute. This tells you that your inputs match your intent in both directions.

Confirm affected includes indirect dependents

Run nx show project <name> to see the resolved inputs and dependencies for a project, including any implicitDependencies. Then change a shared library or a file that a project depends on implicitly, and check that the affected output lists every project you expect.

Validate affected detection and caching separately before combining them. When both are enabled and a result looks wrong, you can't tell which mechanism caused it.

When to use it, when to avoid it, and the alternatives

Adopt this setup when you run a monorepo with many projects and CI time or runner cost is a real bottleneck. The gains grow with the number of projects a typical change leaves untouched, because those are the tasks Nx skips or restores from cache.

Limit it in three cases:

  • Tasks with side effects or external dependencies. End-to-end tests against a live backend, deploys and non-deterministic jobs must not be cached. The Nx caching docs state that cacheable tasks must produce the same output for the same input. These jobs will always run in full, so they set a floor on your pipeline time.
  • Small repositories. With only a few projects, the configuration and debugging effort can outweigh the minutes you save.
  • Strict data policies. Nx Cloud stores build artifacts outside your infrastructure. Evaluate it against your policies before connecting a workspace. Also count the other costs: CI now depends on a network service, someone has to manage accounts and access tokens, and the service has a price.

There is also an ongoing cost. Undeclared inputs, generated files and implicit dependencies can make the project graph drift from reality. When that happens, affected detection misses projects and cache hits return stale results.

Approach Accuracy Setup effort Maintenance Cost
Full pipeline every run Highest None None Highest compute
Path-based CI filters Misses transitive dependencies Low Path lists drift Low
CI-provider cache steps Caches dependencies, not task results Low Cache keys Low
Separate repositories Clear boundaries High Cross-repo versioning Varies
Nx affected + remote cache Graph-based, only as accurate as the graph Moderate Graph accuracy Service fees plus compute

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 ci optimization, 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.