How nx affected decides what to deploy
nx affected answers one question: which projects could behave differently between two commits? To answer it, Nx takes the diff between --base (typically the merge-base with your main branch) and --head (typically the current commit) and gets a list of changed file paths. It assigns each path to the project whose root contains it. A change to charts/payments/values-staging.yaml belongs to the payments project because that directory is the project's root. Nx then walks the project graph in reverse, adding every project that depends on a changed project, directly or transitively. The affected set is the changed projects plus all of their dependents. Nx runs the requested target, here deploy, only on projects in that set that define it.
git diff (base → head) → changed files → owning projects (by root) → dependents via project graph →
deploytargets to run
This mechanism has a consequence for chart layout. Nx only knows what it can attribute. A shared values file or library chart sitting outside every project root will not mark any service as affected on its own. You have to place that file inside a project, or declare the relationship explicitly with an implicit dependency, so the graph reflects it.
Teams adopt selective deployment in a Helm monorepo because the alternative scales badly. If every merge runs helm upgrade for every release, a one-line staging values tweak for one service touches every release in the cluster. Each of those upgrades adds rollout time and a chance of failure. Each one also adds noise for anyone trying to work out what actually changed.
+-----------------------------------------+
| git diff: --base (merge-base) .. --head |
+-----------------------------------------+
v
+-----------------------------------------+
| changed files |
| charts/payments/values-staging.yaml |
| libs/shared-config/defaults.yaml |
| charts/common/values.yaml |
+-----------------------------------------+
v owner = project whose root contains path
+-----------------+ +-----------------+ +----------------------+
| payments | | shared-config | | charts/common: no |
| charts/payments | | libs/shared- | | owning root, marks |
| (has deploy) | | config | | nothing as affected |
+-----------------+ +-----------------+ +----------------------+
| v reverse graph walk (direct + transitive)
| +-----------------------------+
| | dependents of shared-config |
| | orders, billing (implicit) |
| +-----------------------------+
v v
+-----------------------------------------------------+
| affected = changed projects + their dependents |
| run deploy where the target is defined: |
| payments, orders, billing (shared-config: none) |
+-----------------------------------------------------+
Nx maps each changed file to the project whose root contains it, walks the project graph to add dependents, and runs deploy only on affected projects that define the target. A file outside every project root, like charts/common/values.yaml, triggers nothing.
Laying out charts in a Helm monorepo
Nx decides what is affected based on project boundaries, so where a chart lives determines which changes mark its release for deployment.
Two layouts
Co-located: the chart sits inside the service, at apps/orders/chart/. It belongs to the orders project automatically, so a change to the code or the chart affects the same project. No extra wiring is needed.
Separate chart project: the chart lives at deploy/orders-chart/ with its own project.json. That file lists orders under implicitDependencies, because Nx cannot infer the link from YAML. A code change affects the chart through the dependency. A chart-only change affects the chart but not the service's tests or image build.
| Concern | Co-located (apps/orders/chart/) |
Separate (deploy/orders-chart/) |
|---|---|---|
| Ownership | One team owns the code and the manifests | A platform team owns the charts |
| Chart reuse | Low, one chart per service | Higher, charts are shared or templated |
| Release cadence | Chart changes ship with code | Manifests change on their own schedule |
| Wiring | None | implicitDependencies on the service |
Library charts and stray files
A Helm library chart, such as deploy/common-lib/, should be its own Nx project. Each consuming chart then declares it as a dependency. When the shared helpers change, Nx marks every chart that uses them as affected, and only those charts.
Some files, such as deploy/values-global.yaml, sit outside every project root. Do not assume Nx will attribute them correctly. Add them to a named input, for example {workspaceRoot}/deploy/values-global.yaml, and use that input in the deploy target. Projects that read the file then become affected when it changes.
# Separate chart project: deploy/orders-chart/project.json
# implicitDependencies links the chart to the service and to the shared
# library chart, because Nx cannot infer these links from Helm YAML.
# The globalValues named input attributes a file outside every project root.
cat > deploy/orders-chart/project.json <<'EOF'
{
"name": "orders-chart",
"root": "deploy/orders-chart",
"implicitDependencies": ["orders", "common-lib"],
"namedInputs": {
"globalValues": ["{workspaceRoot}/deploy/values-global.yaml"]
},
"targets": {
"deploy": {
"executor": "nx:run-commands",
"inputs": ["default", "^default", "globalValues"],
"options": {
"command": "helm upgrade --install orders deploy/orders-chart -f deploy/values-global.yaml --namespace orders"
}
}
}
}
EOF
# Edits to apps/orders, deploy/common-lib, or deploy/orders-chart now mark
# orders-chart as affected. Check before deploying:
npx nx show projects --affected --base=origin/main
# Run deploy only for the affected chart projects:
npx nx affected -t deploy --base=origin/main
A separate chart project that depends on its service and a shared library chart, with a named input that attributes the global values file to its deploy target.
Defining a non-cacheable deploy target with precise inputs
Give each service a deploy target in its project.json that uses the nx:run-commands executor to call helm upgrade --install. The command passes four things: the release name, the namespace through --namespace, the chart path (the project's own chart/ directory), and one or more --values files. Layer the files from general to specific: the chart's values.yaml, then the shared global file, then values-<env>.yaml. Helm merges them in order, so environment overrides win.
Do not hard-code the environment or image tag. Define configurations such as staging and production that set the namespace and pick the matching values file. Read the image tag from an environment variable that CI sets, typically to the commit SHA, and pass it with --set image.tag=.... The same target then serves every environment, and nx affected -t deploy -c staging selects the right settings.
Declare a named input, for example helmChart, in nx.json. It should cover {projectRoot}/chart/templates/**/*, {projectRoot}/chart/Chart.yaml, {projectRoot}/chart/Chart.lock and {projectRoot}/chart/values*.yaml. Reference it, together with the global values input, from the target's inputs.
Set "cache": false on the target. A deploy is a side effect, not a build artifact. If the target were cached, a matching hash would replay old terminal output and skip helm upgrade without any error, and the cluster would drift from what CI reports. Keep inputs in place anyway. They record exactly which files the deploy depends on, so the project graph stays accurate and you can explain why a project was or wasn't selected.
For external chart dependencies, pin exact versions in Chart.yaml and commit Chart.lock. An upstream chart bump then appears as a change in your diff, and the affected service gets redeployed.
{
"name": "orders",
"targets": {
"deploy": {
"executor": "nx:run-commands",
"cache": false,
"inputs": [
"helmChart",
"{workspaceRoot}/deploy/values-global.yaml",
{ "env": "IMAGE_TAG" }
],
"options": {
"command": "helm upgrade --install orders apps/orders/chart --namespace orders-dev --values apps/orders/chart/values.yaml --values deploy/values-global.yaml --values apps/orders/chart/values-dev.yaml --set image.tag=$IMAGE_TAG"
},
"configurations": {
"staging": {
"command": "helm upgrade --install orders apps/orders/chart --namespace orders-staging --values apps/orders/chart/values.yaml --values deploy/values-global.yaml --values apps/orders/chart/values-staging.yaml --set image.tag=$IMAGE_TAG"
},
"production": {
"command": "helm upgrade --install orders apps/orders/chart --namespace orders-prod --values apps/orders/chart/values.yaml --values deploy/values-global.yaml --values apps/orders/chart/values-production.yaml --set image.tag=$IMAGE_TAG"
}
}
}
}
}
A project.json deploy target with caching disabled. Each configuration sets the namespace and values file, the image tag comes from the IMAGE_TAG variable set by CI, and the helmChart named input (defined in nx.json) is kept for affected detection.
Running nx kubernetes deploys in CI end to end
The pipeline starts by deciding what "changed" means for each environment. Store the commit that last deployed successfully to that environment, either as a moving git tag such as deployed/staging or as a CI variable, and pass it to nx affected as --base, with the current commit as --head. Don't use the merge-base or HEAD~1. Both answer the question "what changed in this commit?", but the deploy needs to know "what changed since the cluster last matched the repo?" If a deploy fails or is skipped, a stored SHA keeps the undeployed changes in scope for the next run. A previous-commit base would silently drop them.
Build and push images for the affected apps first, tagged with the full commit SHA. Then run nx affected -t deploy with an explicit --parallel value. Set it to what your cluster and registry can handle, not to the number of runner cores. Every deploy target receives the image tag the same way, for example through one environment variable that each target forwards to helm upgrade --install as --set image.tag=.... When every target uses the same mechanism, no chart ends up deploying a stale tag.
Use dependsOn to declare ordering, such as a migration job that must finish before the API rolls out. Move CRDs and namespaces into their own project and make the app deploys depend on it. Helm treats cluster-scoped resources specially, and shared ones shouldn't belong to any single service's release.
Move the stored SHA forward only after every selected deploy exits successfully. A partial failure then leaves the base where it was, and the next run retries everything that didn't finish.
#!/usr/bin/env bash
# CI deploy step for one environment. Requires full git history (no shallow clone).
set -euo pipefail
ENV="${DEPLOY_ENV:?set DEPLOY_ENV, e.g. staging}"
TAG_REF="deployed/${ENV}"
# Base = last commit that deployed successfully to this env, not HEAD~1 or merge-base.
git fetch --force origin "refs/tags/${TAG_REF}:refs/tags/${TAG_REF}" || true
BASE="$(git rev-parse --verify --quiet "refs/tags/${TAG_REF}^{commit}" || true)"
HEAD_SHA="$(git rev-parse HEAD)"
if [[ -z "$BASE" ]]; then
# No record yet: treat the whole history as changed.
BASE="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
fi
# Single source of truth for the image tag. Every deploy target forwards it as:
# helm upgrade --install <app> ./charts/<app> -n "$DEPLOY_ENV" \
# --set image.tag="$IMAGE_TAG" --wait
export IMAGE_TAG="$HEAD_SHA"
# 1. Build and push images for affected apps, tagged with the full SHA.
npx nx affected -t docker-push --base="$BASE" --head="$HEAD_SHA" --parallel=3
# 2. Deploy affected apps. Ordering comes from dependsOn in project.json
# (e.g. api deploy depends on its migration job, and every app deploy
# depends on the cluster-infra project that owns CRDs and namespaces).
# Parallelism is sized for the cluster and registry, not runner cores.
npx nx affected -t deploy --base="$BASE" --head="$HEAD_SHA" --parallel=2
# 3. Reached only if every deploy succeeded (set -e). Move the marker forward.
git tag --force "$TAG_REF" "$HEAD_SHA"
git push --force origin "refs/tags/${TAG_REF}"
A CI script that uses a per-environment git tag as the --base for nx affected, builds and deploys affected apps with one shared image tag, and moves the tag forward only after every deploy succeeds.
Verifying the selection and providing escape hatches
Before trusting the selection, check it. Run nx show projects --affected with the exact --base and --head values the deploy job uses. If you use different refs, you are checking a different selection than the one that ships. Print the list as a CI log line so reviewers see which releases a merge will touch. If a project appears that you did not expect, run nx graph --affected to find the dependency edge that pulled it in. The usual causes are a shared values file or a library chart that many services import.
Add a dry-run step to pull requests. For each affected project, run helm upgrade --install --dry-run against the target cluster. This catches schema errors and values that the API server rejects. For a reviewable change, run helm template on the chart at the base commit and again at the head commit, then diff the two outputs. A rendered diff is more useful in review than a values diff because it shows the manifests that will actually be applied.
Affected detection only reads git history. It has no view of what is running in the cluster, so you need manual paths for cases git cannot see:
- Drift recovery: someone ran
kubectl edit, or a release was rolled back by hand. Runnx run-many -t deploy --projects=<name>to reapply what is in git. - Cluster rebuilds: a new or restored cluster has no releases. Run
nx run-many -t deployacross all projects. - Secret rotation: the value changed in your secret store but no file in the repo changed. Redeploy the consuming services by name.
Expose these paths as manual CI triggers that take an optional project list. Do not move the stored base SHA when they run.
When to use it, when to avoid it, and the alternatives
This approach pays off for platform teams running many independently released services from one repository. In that setup, a full redeploy on every merge wastes pipeline minutes and makes rollouts noisy. Helm-level selection keeps each release scoped to the services that actually changed.
When to avoid it
It is the wrong choice in three situations:
- A handful of services. Deploying everything takes about as long as computing what changed.
- Tightly coupled releases. If services must ship in lockstep, selecting a subset only creates partial states.
- No capacity to maintain base SHA state. Selection is only as reliable as the reference commit it compares against.
Trade-offs
- Cluster drift is invisible. Nx compares Git commits, not live cluster state. Manual edits and failed rollbacks go unnoticed.
- A wrong
--basefails silently. If the base points past the last successful deploy, services are skipped without any error. - Inputs are hard to tune. Overly broad inputs bring back deploy-everything. Overly narrow inputs miss real changes, such as a values file outside the declared pattern.
- Shared changes fan out. Editing a library chart or a base image marks many services affected at once.
- Parallel releases can hit rate limits. Running many releases at the same time can trip Kubernetes API or registry rate limits. Cap parallelism.
Alternatives
| Approach | Deploys | Detects drift | Main cost |
|---|---|---|---|
| Nx affected per chart | Changed services only | No | Base SHA and input maintenance |
| Single umbrella chart | Everything, as one release | No | Coupled rollouts, large blast radius |
| Always deploy all | Everything, every time | Partially, by overwriting | Slow pipelines, noisy rollouts |
| Pull-based GitOps | Whatever differs from Git | Yes, continuously | An in-cluster controller to operate |
Pull-based reconciliation is the strongest alternative when drift matters more than pipeline speed.
Impact on cost and engineering time
The savings are real, but they come with ongoing work. On the cost side, the deploy target adds configuration surface: every chart's inputs list, the implicit dependencies on shared values files, and the CI logic that resolves base and head. Someone has to review the inputs whenever a chart starts reading a new file. If they don't, a change silently stops triggering a deploy. You also have to store the last successfully deployed SHA per environment and protect it, because a wrong SHA produces a wrong selection.
On the savings side, pipelines get shorter, rollouts get smaller, and services the change never touched don't restart. Fewer restarts also means fewer cold caches, fewer dropped connections, and less noise when you are hunting for the cause of an alert.
Illustrative estimate (assumed numbers, not a benchmark). Assume N = 30 services, t = 2 minutes per Helm release, k = 3 affected services per merge, and o = 1 minute of affected-graph overhead.
- Time saved per merge = (N − k) × t − o = (27 × 2) − 1 = 53 minutes
- At 15 merges a day, that is about 795 pipeline-minutes saved per environment.
Keep a scheduled full reconciliation in that budget, for example a nightly run of all 30 releases (60 minutes). Selective deploys only act on Git changes. They cannot correct drift from manual edits, failed partial rollouts, or a lost SHA. With the nightly run included, the net saving is about 735 minutes a day. Plug in your own N, t, k and merge rate before deciding whether the setup is worth maintaining.
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, 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.