What it is
"Nx affected for Kustomize" is a way of running a Kubernetes monorepo so that each commit renders and deploys only the overlays it can change. Nx is a build system for monorepos. It models a repository as a graph of projects and uses nx affected to run tasks only for projects touched by a git diff. Kustomize builds Kubernetes manifests from a shared base plus per-environment overlays that patch it. Combining them means treating each base and each service-environment overlay as an Nx project, declaring the dependencies Nx cannot infer, and letting nx affected pick which overlays to render and deploy.
This is a pattern built from standard features: project configuration, implicitDependencies, named inputs, cached targets and the affected commands. It is not a dedicated Nx plugin for Kustomize. The rest of this article covers the parts Nx does not handle for you.
Who should use it
This pattern fits teams that already use Nx, or are willing to adopt it, and that keep Kubernetes manifests in the same repository as the services they deploy. It is most useful when:
- There are many services, each with several overlays (dev, staging, prod), so a full render-and-deploy on every merge takes noticeable pipeline or review time.
- Shared bases or components are reused by many overlays. A change to one of them needs to reach every consumer, and no consumer can be missed.
- Deploys are gated by review or approval, so every redundant diff costs a person time.
It is a poor fit for small repositories with a handful of overlays, where deploying everything is simpler. It also fits poorly when manifests live in a separate repository from the code, or when most overlays depend on remote bases or Helm charts that change outside the repository. The last section covers these trade-offs in more detail.
How it works, end to end
The mechanism has five parts, each covered in a section below:
- Projects. Every base and every service-environment overlay gets a
project.json, so Nx can map a changed file to a specific deployable unit. - Edges. Nx does not parse
kustomization.yaml, so each overlay declares its bases and components inimplicitDependencies. A base change then marks every consuming overlay as affected. - Render and deploy targets. A cached
rendertarget runskustomize buildwith tightly scoped inputs. Adeploytarget depends onrenderand applies, or commits, only the rendered output. - CI comparison. CI runs
nx affectedbetween an explicit base SHA, ideally the last successfully deployed commit for each environment, and the current head. It then filters the affected projects down to the environment being deployed. - Guards. A consistency check fails the build when directories or kustomization paths disagree with the graph. A periodic full render catches what the graph cannot see.
How nx affected sees Kustomize overlays (and what it cannot see)
nx affected decides what to run by diffing two git refs, usually a base such as main against the current head, and mapping each changed file to the project whose root contains it. It then walks the project graph to add dependents and, for task-level decisions, checks each target's configured inputs. A project counts as affected only if a changed file sits under its root, matches one of its inputs, or belongs to a project it depends on.
Nx does not read kustomization.yaml. A resources: ../../base line, a components: entry, or a strategic-merge patch pulled from a shared directory means nothing to the graph. Nx infers dependencies for languages its plugins understand. For Kustomize, it only knows about edges you declare yourself.
That gap produces two opposite failure modes:
- Missed environments. You edit a Deployment in
base/payments, andoverlays/payments/prodreferences it by relative path. Nx sees a change in the base project but has no edge to the overlay, so prod is never rendered or deployed. The pipeline passes and the cluster drifts. - Full redeploys. To avoid the first problem, teams often mark the whole
k8s/tree as a global input or make every overlay implicitly depend on every base. After that, a typo fix in one service's patch redeploys every environment, and the selective deploys you wanted are gone.
This tutorial is for platform and backend engineers running monorepo deployments with several services, each with dev, staging and prod overlays. In that setup, deploying everything on every merge is slow, and silently skipping an environment is dangerous.
git diff main...HEAD → changed: k8s/base/payments/deployment.yaml
│
▼
┌───────────────────────────────────────────────┐
│ nx affected │
│ 1. map changed file → project root / inputs │
│ 2. add dependents via declared graph edges │
│ kustomization.yaml is never parsed │
└─────────┬─────────────────────────────────────┘
▼
┌───────────────────┐ ┌─────────────────────────┐
│ base-payments │ ../../base │ overlay payments-prod │
│ AFFECTED │◄─ ─ ─ ─ ─ ─ ─ │ NOT affected → skipped │
└───────────────────┘ not an Nx edge│ (resources: ../../base) │
└─────────────────────────┘
Failure mode 1: no declared edges (missed environments)
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ base changed │──►│ prod skipped │──►│ CI green, │
│ │ │ (not rendered)│ │ cluster drifts│
└───────────────┘ └───────────────┘ └───────────────┘
Failure mode 2: whole k8s/ tree as global input (full redeploys)
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ one patch │──►│ every overlay │──►│ redeploy all │
│ typo fixed │ │ affected │ │ environments │
└───────────────┘ └───────────────┘ └───────────────┘
nx affected maps changed files to project roots and declared graph edges, so the overlay's ../../base reference in kustomization.yaml is invisible to it. Teams then either miss environments or, after over-declaring inputs, redeploy every environment.
Laying out bases and overlays as Nx projects
Give each deployable unit its own directory and its own project.json. A layout that works well is deploy/base/<service> for the shared manifests and deploy/overlays/<env>/<service> for each environment's patches, so deploy/base/orders-api sits alongside deploy/overlays/staging/orders-api and deploy/overlays/prod/orders-api.
Granularity
Nx computes affected status per project, so granularity matters. If you make one project per environment that holds every service, a one-line patch to orders-api in staging marks the whole staging environment as affected and redeploys services that did not change. One project per service-environment pair keeps the blast radius as small as the change.
Naming and tags
Use predictable names that encode both dimensions, such as k8s-base-orders-api and k8s-orders-api-staging. Predictable names make CI logs readable and let you derive the environment from the project name if you ever need to.
Tags do the filtering work. For example:
- Give every overlay
type:k8s-overlay,env:staging(orenv:dev,env:prod) andservice:orders-api. - Give bases
type:k8s-baseand the matchingservice:tag.
Later steps can then select "affected staging overlays" without hard-coding project lists. You can also use the same tags in module-boundary lint rules, for example to stop one overlay from referencing another environment's files.
What each project.json contains
The base project's project.json needs only a name, a root pointing at deploy/base/orders-api, a projectType of library, and its tags. A base is never deployed on its own, so it gets no deploy target.
The overlay's project.json has the same fields, with projectType set to application and the overlay tags. It is also where you will add render and deploy targets in the next section.
cat > deploy/base/orders-api/project.json <<'EOF'
{
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"name": "k8s-base-orders-api",
"root": "deploy/base/orders-api",
"projectType": "library",
"tags": ["type:k8s-base", "service:orders-api"]
}
EOF
cat > deploy/overlays/staging/orders-api/project.json <<'EOF'
{
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"name": "k8s-orders-api-staging",
"root": "deploy/overlays/staging/orders-api",
"projectType": "application",
"tags": ["type:k8s-overlay", "env:staging", "service:orders-api"],
"implicitDependencies": ["k8s-base-orders-api"],
"targets": {
"deploy": {
"executor": "nx:run-commands",
"options": {
"command": "kubectl apply -k deploy/overlays/staging/orders-api"
}
}
}
}
EOF
npx nx show project k8s-orders-api-staging
This script writes a project.json for the orders-api base and one for its staging overlay. Both carry the naming convention and tags used for filtering, and the overlay declares an implicit dependency on the base so base changes also mark the overlay as affected.
Wiring dependency edges, render targets and cacheable inputs
Start with the edges Nx cannot infer. In the overlay's project.json, list the base under implicitDependencies, along with any component or shared-patch project it pulls in through components or a relative path. In the base, list the application project whose image it deploys. A change to the app's source now marks the base and every overlay above it as affected. Without those edges, a relative path in resources is invisible to the graph.
Give each overlay a render target that runs kustomize build on the overlay directory and writes to a per-project folder under dist/. Add a deploy target with dependsOn: ["render"], so deploy only ever consumes the rendered file and never re-runs Kustomize with different inputs.
For safe caching, set render inputs to the overlay's own files and the same input from its dependencies (^). Those files are the kustomization files, patches, and generator and .env sources. Add a runtime input that prints the Kustomize version, so an upgrade invalidates the cache. Set outputs to the render directory.
Root files fan out because the default named input usually includes sharedGlobals. Define a narrower named input for manifest projects that excludes root config and lockfiles, and use it for render.
The image tag has to change the rendered output, or caching hides the change. Either run kustomize edit set image against a temporary copy of the overlay, or generate a small images patch from the app's commit SHA. If the tag arrives as an environment variable, declare it as an env input.
{
"name": "payments-overlay-prod",
"projectType": "application",
"implicitDependencies": ["payments-base", "k8s-component-monitoring"],
"namedInputs": {
"manifests": ["{projectRoot}/**/*"]
},
"targets": {
"render": {
"executor": "nx:run-commands",
"inputs": [
"manifests",
"^manifests",
{ "runtime": "kustomize version" },
{ "env": "IMAGE_TAG" }
],
"outputs": ["{workspaceRoot}/dist/k8s/payments-overlay-prod"],
"options": {
"command": "out=\"$PWD/dist/k8s/payments-overlay-prod\" && tmp=$(mktemp -d) && cp -r deploy \"$tmp/\" && mkdir -p \"$out\" && cd \"$tmp/deploy/payments/overlays/prod\" && kustomize edit set image payments-api=registry.example.com/payments-api:${IMAGE_TAG:?IMAGE_TAG must be set} && kustomize build . > \"$out/manifest.yaml\" && rm -rf \"$tmp\""
}
},
"deploy": {
"executor": "nx:run-commands",
"dependsOn": ["render"],
"cache": false,
"options": {
"command": "kubectl apply -f dist/k8s/payments-overlay-prod/manifest.yaml"
}
}
}
}
Overlay project.json with implicit edges to its base and a component, a narrow manifests input that skips sharedGlobals, and a cached render target keyed on Kustomize version and IMAGE_TAG; the tag is set in a temp copy, and deploy only applies the rendered file.
Running affected renders and deploys in CI
With inputs settled, CI needs to tell Nx which two commits to compare. Pass both explicitly as nx affected -t render --base=<sha> --head=<sha>. Otherwise Nx falls back to its configured default base, which may not match what CI intends.
| Base choice | What it answers | Weakness |
|---|---|---|
git merge-base origin/main HEAD |
What this branch changed relative to main | Ignores drift. A failed or skipped deploy is never retried. |
| Last successfully deployed SHA per environment | What changed since the cluster last matched Git | Requires state you must store and maintain |
For deploys, the second option is safer. Store it as a per-environment Git tag such as deployed/prod. Move it with a force-push only after the apply succeeds, so a failed run leaves the base where it was and the next run picks up the backlog. Fetch that tag explicitly in CI. Lock it against concurrent pipelines, or two runs can race and skip a commit.
Both options need history. A depth-1 clone has no base commit, so the merge-base cannot be computed and the diff is wrong. Configure checkout for full history, or run git fetch --unshallow along with fetching main and the deploy tags.
On a merge to main, render every affected overlay. Rendering is cheap and catches broken bases early. Deploy more narrowly:
- List affected projects with
nx show projects --affected. - Keep only those tagged
env:prod, or matching a pattern like*-prod. - Pass that list to
nx run-many -t deploy --projects.
Because overlays depend on their base, a change to the base marks every consuming overlay as affected. The render step fans out across all environments, and each environment's deploy job then applies only its own overlays.
#!/usr/bin/env bash
set -euo pipefail
ENV=prod
TAG="deployed/${ENV}"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow origin
fi
git fetch origin main
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
BASE_SHA=$(git rev-parse "${TAG}^{commit}") # last successful prod apply
HEAD_SHA=$(git rev-parse HEAD)
npx nx affected -t render --base="$BASE_SHA" --head="$HEAD_SHA"
PROJECTS=$(npx nx show projects --affected --base="$BASE_SHA" --head="$HEAD_SHA" \
| grep -E -- "-${ENV}\$" | paste -sd, - || true)
if [ -n "$PROJECTS" ]; then
npx nx run-many -t deploy --projects="$PROJECTS"
else
echo "No ${ENV} overlays affected between ${BASE_SHA} and ${HEAD_SHA}"
fi
git tag --force "$TAG" "$HEAD_SHA"
git push --force origin "refs/tags/${TAG}"
*A prod deploy script that unshallows the clone and uses the per-environment deployed/prod tag as the base. It renders every affected overlay, applies only the affected -prod projects, and moves the tag forward only after the deploy succeeds.
Applying directly versus committing rendered manifests for GitOps
There are two ways to finish that step, and they behave differently once a cluster drifts or a deploy fails halfway.
With direct apply, the CI job runs kubectl apply against the cluster using the manifest it just rendered. This path is short and gives immediate feedback. However, CI needs cluster credentials, and the only record of what was deployed is the job log. Overlays that were not affected are never re-applied, so any manual edits to their live resources stay in place until the next change touches that environment.
With GitOps, the job commits the rendered output to an environment repository or branch, and a controller syncs it. The controller is still the component that applies changes, but affected detection remains useful. Unaffected environments get no commit, so a base change touching two of five overlays produces a pull request with two changed directories instead of five rewrites that differ only in formatting or ordering. Reviewers read only real changes, and each controller sync event corresponds to an actual intent.
| Concern | Direct apply from CI | Commit rendered manifests |
|---|---|---|
| Cluster credentials in CI | Required | Not required |
| Audit trail | Job logs | Git history |
| Drift correction | Only on next affected run | Continuous, by the controller |
| Rollback | Re-run an earlier pipeline | Revert a commit |
Choose one path per environment. If CI applies to an environment that a controller also syncs, there are two writers. The controller will revert CI's changes, or the two will fight over fields, and neither result explains itself in a log.
Verifying the graph and guarding against silent skips
Before trusting the pipeline, check that Nx draws the edges you think it does. Make a throwaway change to a shared base on a branch. Run nx show projects --affected and confirm that every overlay built on that base appears in the list. Then open nx graph to inspect the edges themselves. If an overlay is missing, it will not render and will not deploy, and CI will still report green. That silent skip is the main failure mode of this setup.
A consistency check in CI
Manual inspection does not scale, so add a CI step that fails the build when the repository and the project graph disagree. The script should do two things:
- Match directories to projects. List every overlay directory and fail if any directory has no corresponding Nx project.
- Match kustomization paths to dependencies. Parse each
kustomization.yamland collect the local paths underresources,componentsandpatches. Fail if any of those paths falls inside another project that is not declared inimplicitDependencies.
Run this check on every pull request, ahead of the affected step.
What the graph cannot see
- Remote bases and
helmCharts. These can change upstream with no commit in your repository, so Nx will never mark the overlays that use them as affected. Pin them to immutable refs and chart versions. - Cluster drift. Manual edits made in the cluster are invisible to Nx.
- Failed or partial applies. A rerun on the same commit can compute an empty affected set, so the failed environments are never retried.
- Stale cache hits. If a render target's inputs leave out a file that changes the output, Nx can replay an old render from cache.
To cover all four, schedule a periodic job that renders every overlay with the cache skipped and diffs the output against the live cluster or the committed manifests.
How it saves engineering time
The savings come from work that no longer happens, not from making any single step faster:
- Fewer renders and applies per commit. A change to one service's staging patch renders and deploys one overlay instead of every overlay in the repository. Pipelines finish sooner and hold the deploy queue for less time.
- Smaller review surface. Reviewers see diffs only for overlays whose rendered output can actually change. This matters most where deploys need human approval, or where rendered manifests are committed for GitOps and every changed directory has to be read.
- Cache reuse. Because
renderis cached on narrow inputs, re-running a pipeline or rendering an unchanged overlay replays the stored output instead of invoking Kustomize again. - Less time spent on cross-environment surprises. Declared edges plus the consistency check mean a base change reliably reaches every consuming overlay. That replaces manual "did prod get this?" checks and the debugging that follows a silently skipped environment.
- Reruns that pick up where they failed. Using the last deployed SHA as the base means a failed deploy is retried automatically on the next run, without someone working out by hand which environments are behind.
These gains have to outweigh the upkeep described in the next section. The illustrative estimate at the end of that section shows how to work out whether they do for a given repository.
When to use it, when to avoid it, and the cost trade-off
The pattern pays off when rendering or deploying is expensive: dozens of services across several environments, deploys that wait on human review, or applies slow enough that redundant ones hold up the queue. With three or four overlays, skip it. Rendering and applying everything on each commit is simpler, takes about as long, and leaves no project graph that can drift away from the YAML.
| Approach | Decides what changed | Main weakness |
|---|---|---|
| Render everything | Nothing; always full | Large diffs, redundant deploys |
| Path-filter CI triggers | Glob patterns per pipeline | Misses shared bases unless globs are kept in sync by hand |
| GitOps controller diffing full renders | Live cluster state | Still renders all overlays; no CI-side review scoping |
| Nx affected | Project graph plus implicit dependencies | Graph upkeep, deployed-SHA bookkeeping |
The development cost is ongoing. Each new overlay needs a project.json, explicit edges to its bases, and correct inputs. Nx's graph view fills up with config-only projects. The deployed-SHA record also needs an owner, because if it goes stale, affected runs compare against the wrong base and skip overlays without any error.
Illustrative estimate (assumed numbers, not a benchmark). Saved minutes per day = commits × (total overlays − affected overlays) × minutes per overlay. Assume 40 commits a day, 30 overlays, 3 affected per commit on average, and 1 minute per overlay to render, diff, and review. That gives 40 × 27 × 1 = 1,080 minutes of pipeline and review time saved per day. Change the overlay count to 4 and the result is too small to justify maintaining the graph.
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 kustomize, 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.