How Nx affected works and why Helm charts are invisible to it

nx affected answers one question: which projects could behave differently because of the changes between two commits? It works in three steps. First, it diffs a base commit against a head commit, using --base and --head or the defaults in nx.json, to get a list of changed files. Second, it maps each file to the project whose root directory contains it. Third, it walks the project graph in reverse and marks every project that depends on a changed project. Targets such as build or deploy then run only for that set.

The graph comes from two sources. One is explicit configuration, such as project.json or package.json files that declare a project. The other is source analysis, where Nx plugins read import statements to infer edges between projects. A TypeScript service that imports a shared library gets an edge automatically.

A Helm chart gets neither. A directory with Chart.yaml, values.yaml and templates/ has no project file, and no plugin infers a project from it. Its templates refer to a container image by a string in values, not by an import Nx can follow. Changes to the chart don't map to any project, so nx affected never lists it. Changes to the service it deploys never reach it either, because no edge connects them.

The fix is to register each chart as a project and declare its edges by hand. That effort is worth it for platform and backend teams that ship many Helm releases from one monorepo, where redeploying everything on every merge wastes time.

 git diff --base=main --head=HEAD
            |
            v
 +---------------------------+
 | changed files             |
 |  libs/shared/src/util.ts  |
 |  charts/api/values.yaml   |
 +---------------------------+
            |  map each file to the project whose root holds it
            v
 +-------------------------------------------------------------+
 | PROJECT GRAPH (project.json files + inferred import edges)  |
 |                                                             |
 |  +-------------+  import edge   +-------------+             |
 |  | libs/shared |<---------------| apps/api    |             |
 |  | project.json|  (inferred)    | project.json|             |
 |  +-------------+                +-------------+             |
 |    changed                       dependent -> affected      |
 +-------------------------------------------------------------+

 +-------------------------------------------------------------+
 | OUTSIDE THE GRAPH: charts/api/                              |
 |   Chart.yaml   values.yaml   templates/                     |
 |   no project.json, no plugin infers a project               |
 |   image tag is a values string, not an import               |
 |   values.yaml changed  ->  maps to no project               |
 |   apps/api changed     ->  no edge reaches chart            |
 +-------------------------------------------------------------+
 nx affected -t deploy  =>  libs/shared, apps/api  (chart skipped)

Nx maps changed files to projects and walks import edges back to dependents, so the shared library change marks apps/api as affected, while the unregistered Helm chart has no project and no edge and is never selected.

Registering a Helm chart as an Nx project with lint, template and deploy targets

Start by placing a project.json in the chart directory, alongside Chart.yaml. Give the project a stable name (for example payments-chart), set root to the chart path, and mark it as an application. Nx now treats the folder as a node in the graph, and any file change under it marks the project as affected.

Define three targets with the nx:run-commands executor. Set cwd to the chart root so relative paths resolve the same way locally and in CI.

  • lint runs helm lint with the values file for the target environment. A chart can pass lint with its defaults and still fail with production overrides, so lint with the values you actually ship.
  • template runs helm template and pipes the rendered manifests into a validation step, such as a schema validator. This catches problems that lint misses, including bad indentation in a conditional block or a required field that a values override removed.
  • deploy runs helm upgrade --install with an explicit release name, --namespace, and --values pointing at the environment file. --install makes the command idempotent on first rollout.

Set cache: true on lint and template. Their output depends only on the chart files and values, so a cache hit is a correct answer.

Set cache: false on deploy. A cache hit replays the stored terminal output and exit code without running the command. The CI log would show a successful deploy even though nothing reached the cluster. The cluster state is also outside anything Nx hashes, so it has no basis for deciding that a deploy is safe to skip.

{
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "name": "payments-chart",
  "root": "charts/payments",
  "projectType": "application",
  "targets": {
    "lint": {
      "executor": "nx:run-commands",
      "cache": true,
      "options": {
        "cwd": "charts/payments",
        "command": "helm lint . --values values-prod.yaml"
      }
    },
    "template": {
      "executor": "nx:run-commands",
      "cache": true,
      "options": {
        "cwd": "charts/payments",
        "command": "helm template payments . --namespace payments --values values-prod.yaml | kubeconform -strict -summary"
      }
    },
    "deploy": {
      "executor": "nx:run-commands",
      "cache": false,
      "dependsOn": ["lint", "template"],
      "options": {
        "cwd": "charts/payments",
        "command": "helm upgrade --install payments . --namespace payments --values values-prod.yaml --wait"
      }
    }
  }
}

A project.json placed next to Chart.yaml that registers the chart as the Nx project payments-chart, with cacheable lint and template targets and a non-cacheable deploy target that runs only after both checks pass.

Wiring implicit dependencies, image builds and immutable tags

Nx only knows what the chart depends on if you declare it. Add implicitDependencies to the chart's project.json listing the application whose image it deploys, plus any shared library chart or common values directory it consumes. Shared values directories need to be registered as Nx projects themselves, so they can appear as nodes in the graph. With those edges in place, a change to the service's source code marks the chart as affected, and a change to a shared base chart marks every chart that consumes it.

Implicit dependencies decide whether the chart is affected. dependsOn decides when its deploy target runs. Point the chart's deploy target at the application's image build-and-push target so Nx runs the push first. If the push fails, the deploy never starts. Without this ordering, Helm can upgrade the release before the registry has the new image, and the pods either fail to pull it or keep running the old one.

Use the commit SHA as the image tag, and never a mutable tag like latest. Pass it at deploy time with --set image.tag=<sha> or a values file that CI generates. This does two things. Every rollout points at an exact build. And Helm sees a changed value, so it renders a new pod spec. With a reused tag, the rendered manifest can be identical to the previous one, so nothing restarts.

A missing edge fails without any error. The application changes, the chart is never marked affected, CI reports green, and the cluster keeps running the old version.

{
  "name": "orders-api-chart",
  "root": "charts/orders-api",
  "projectType": "application",
  "implicitDependencies": [
    "orders-api",
    "base-chart",
    "shared-values"
  ],
  "targets": {
    "deploy": {
      "executor": "nx:run-commands",
      "dependsOn": [
        {
          "projects": ["orders-api"],
          "target": "docker-push"
        }
      ],
      "options": {
        "command": "helm upgrade --install orders-api charts/orders-api --namespace orders -f shared/values/common.yaml --set image.tag=$GIT_SHA --wait"
      }
    }
  }
}

A chart's project.json that uses implicitDependencies to link the chart to its app, base chart and shared values project. dependsOn runs the app's docker-push first, and the deploy pins the image tag to the commit SHA in GIT_SHA.

Scoping inputs so only the right environments roll out

…running the old image. The opposite failure is quieter but costs more. Every chart gets marked affected and redeployed to every environment. To avoid that, you need to know which mechanism decides what runs.

nx affected selects projects by mapping changed files to project roots, so a README edit inside a chart still selects that chart. Target inputs then decide each target's hash. Define named inputs in nx.json, such as chartCommon for Chart.yaml and templates/**, with !{projectRoot}/**/*.md and test fixtures excluded. Give deploy-staging the inputs chartCommon plus values-staging.yaml, and give deploy-prod the inputs chartCommon plus values-prod.yaml. With caching enabled on those targets, an unchanged hash replays from cache instead of running helm upgrade.

That replay only reflects reality if nothing else changed the cluster. A cache hit tells you the inputs matched an earlier run. It does not tell you the release still exists. Keep deploy caches local to the CI runner, or include the cluster context in the hash.

Shared inputs widen the blast radius. A workspace-level values-global.yaml listed in sharedGlobals, or a library chart that every service depends on through ^, changes the hash of every downstream target.

Approach Affected scope Maintenance cost
One global values file Every chart, every environment Low
Per-environment global files Every chart, one environment Moderate
Library chart split by concern (probes, ingress, RBAC) Only charts consuming that part Higher: more versions and more edges

Split a shared input only when your CI history shows it causing unnecessary rollouts.

// nx.json (Nx parses this file with comments allowed)
{
  "namedInputs": {
    // Files that change what Helm renders, minus docs and test fixtures
    "chartCommon": [
      "{projectRoot}/Chart.yaml",
      "{projectRoot}/templates/**",
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/tests/**"
    ],
    // Keep this small: everything here re-hashes every target that uses it
    "sharedGlobals": []
  },
  "targetDefaults": {
    "deploy-staging": {
      "cache": true,
      "inputs": [
        "chartCommon",
        "^chartCommon",
        "{projectRoot}/values-staging.yaml",
        "{workspaceRoot}/helm/values-global-staging.yaml",
        { "env": "KUBE_CONTEXT" }
      ]
    },
    "deploy-prod": {
      "cache": true,
      "inputs": [
        "chartCommon",
        "^chartCommon",
        "{projectRoot}/values-prod.yaml",
        "{workspaceRoot}/helm/values-global-prod.yaml",
        { "env": "KUBE_CONTEXT" }
      ]
    }
  }
}

Named inputs and per-target inputs in nx.json, so a values-prod.yaml edit changes only the deploy-prod hash, README and test fixture edits change no deploy hash, per-environment global values files stand in for one global file, and the cluster context is part of the cache key.

Running nx kubernetes deploy in CI with the correct base commit

The base for --base should be the commit that is actually running in the target environment, not HEAD~1 or the merge base with main. If a deploy to staging failed or was skipped, HEAD~1 drops that change from the comparison and it never ships. The merge base answers "what does this branch change," which is a different question from "what has this cluster not seen yet."

Base choice Compares against Failure mode
HEAD~1 Previous commit Silently skips changes from failed or batched deploys
Merge base with main Branch point Ignores drift between main and the environment
Last successful deploy per environment Commit running in that environment Requires you to record state reliably

There are two common places to record that state. The first is a moving git tag per environment, such as deployed/prod, which you force-update only after every release succeeds. The second is Helm release metadata: pass the commit SHA through helm upgrade --description or as a chart annotation, then read it back from helm history. Tags are easier to query from CI. Helm metadata stays accurate when someone deploys by hand.

The base commit must exist in the clone. Shallow checkouts often do not include it, so the git comparison either errors out or, in some setups, falls back to marking every project affected. Fetch full history, or run git fetch --unshallow, before calling Nx.

Run nx show projects --affected with the same explicit --base and --head first, and log the output so reviewers can see exactly what will roll out. Then run nx affected -t deploy with those same SHAs and --parallel=1. Running deploys one at a time prevents releases that share a namespace or install CRDs from racing each other. Raise the limit only for charts you know are fully isolated.

Validating the graph before trusting it

The affected graph is only as accurate as the project definitions behind it, so test it before a pipeline uses it to decide what reaches production. The check takes a few minutes. Run it on a throwaway branch.

Three deliberate changes

  1. A service change. Edit one source file in a single service. Run nx show projects --affected against your main branch. The output should include that service, its image project and its chart. No other charts should appear.
  2. A shared library change. Touch a library that several services import. Every chart whose service depends on it should now appear. If a chart is missing, its implicitDependencies or the image project's dependency on the service is probably not wired up. If every chart in the repo appears, an input pattern is probably too broad.
  3. A values file change. Edit only the staging values file for one chart. That chart should be affected, and the input scoping from the earlier section should keep its production configuration out of the rollout. The service and image should not rebuild.

Reset between steps so each result reflects one change only. When a result looks wrong, run nx graph --affected to open the visual graph. It shows the edge, or the missing edge, that explains the result much faster than reading every project.json.

Repeat when the repo changes shape

Re-run these checks whenever you add, rename or move a chart. A project.json is a manual description of a chart directory, and nothing forces it to stay in sync. Suppose someone renames a chart folder but not the root or the input globs. The chart then stops showing up as affected, and nothing reports an error. It just never gets deployed.

When to avoid it, alternatives, and impact on cost and engineering time

simply stops deploying, and the first symptom is a stale release in production.

That silent failure is the general weakness: Nx only sees file changes in the repository. It misses a rebuilt base image, cluster-level config such as CRDs or quotas, chart dependencies resolved at install time, rotated secrets, and drift from a manual helm upgrade or an out-of-band rollback. It also has no notion of ordering, so a contract change between two services still needs an explicit rollout sequence. Schedule a periodic full deploy, such as nightly, to reconcile drift. Keep a manual override that deploys a named project, or every project, regardless of the graph.

Approach How it picks what to deploy Main weakness
Deploy everything on every merge It doesn't; all charts Slow pipelines, largest blast radius
Per-service pipelines with path filters CI path globs Duplicated config, blind to shared libraries
Nx affected Project graph Extra config surface, blind to changes outside the repo
GitOps controller Continuous diff of the cluster against Git Another system to operate; CI still has to choose image tags

Illustrative estimate (assumed numbers, not a benchmark): deploy time saved per merge = (N − A) × t. With N = 30 charts, A = 3 affected and t = 2 minutes per release, that is (30 − 3) × 2 = 54 minutes. Fewer releases per merge also means fewer diffs to review and fewer services exposed to a bad change.

The cost is maintenance. Every new chart needs project config and a check against the graph. If you run only a handful of services, or your charts usually change together, deploying everything on each merge is simpler.

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 affected helm deploy, 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.