How Nx affected works for an nx terraform monorepo, and who it suits
Each Terraform root module, such as networking/prod or app/staging, becomes an Nx project with its own project.json. Each shared module under modules/ becomes one too. The project graph then mirrors deployable units, the things you actually run plan against, instead of the folder tree.
nx affected takes a base SHA and a head SHA and asks git which files changed between them. It maps each file to the project whose root contains it, then walks the graph to every project that depends on those projects. Nx does not parse HCL, so the dependency edges come from configuration you declare (covered in section 3). A change to modules/vpc therefore marks every stack that sources it, not just the module itself.
Example graph
modules/vpc→networking/staging,networking/prodmodules/ecs-service→app/staging,app/prodnetworking/prod⇢app/prod(remote-state edge:app/prodreads outputs viaterraform_remote_state)networking/staging⇢app/staging(remote-state edge)
Solid arrows are module sources. Dashed arrows are remote-state reads, which no file path reveals.
Who it suits
It pays off when a repo has dozens of root modules and a full plan across all of them takes long enough that people wait on CI or skip it. If you have a handful of stacks with no shared modules, a per-directory CI job filtered on path changes is simpler and loses nothing. Nx earns its overhead when modules are shared, because path filters cannot follow the transitive edges.
base SHA..head SHA ─► git diff ─► owning projects ─► walk to dependents
┌───────────────┐ ┌─────────────────────┐
│ modules/vpc │ │ modules/ecs-service │
└─┬──────────┬──┘ └──┬──────────────┬───┘
│ ▼ ▼ │
│ ┌──────────────────┐ remote state ┌─────────────┐ │
│ │networking/staging│┄┄┄┄┄┄┄┄┄┄┄┄┄►│ app/staging │ │
│ └──────────────────┘ └─────────────┘ │
▼ ▼
┌──────────────────┐ remote state ┌──────────────────┐
│ networking/prod │┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄►│ app/prod │
└──────────────────┘ └──────────────────┘
│ ▼ module source edge (shared module → root stack)
┄┄► remote-state edge (terraform_remote_state read; must be declared)
Every box is an Nx project with its own project.json.
Shared modules feed root stacks through module-source edges, and stacks feed each other through declared remote-state edges, so nx affected follows both from a changed file to every dependent stack.
Defining validate, fmt-check, and plan targets with nx:run-commands
Each root module becomes an Nx project with a project.json that declares three targets, all using the nx:run-commands executor with options.cwd set to the project directory (for example, infra/stacks/payments). Setting cwd matters because Terraform resolves configuration, the .terraform directory, and the lock file relative to the working directory. Without it, commands run from the workspace root and operate on the wrong files or none at all.
The three targets
- fmt-check runs
terraform fmt -check. It exits non-zero if any file needs reformatting, which makes it a cheap, fast gate. - validate runs
terraform init -backend=falsefollowed byterraform validate. Validation needs providers and modules installed, but it does not need state. Skipping the backend means this target needs no cloud credentials and can run on any pull request, including from forks. - plan runs
terraform init -input=falsewith the stack's own backend settings (typically-backend-config=backend.hclcommitted beside the stack), thenterraform plan -input=false -out=tfplan. Set"parallel": falseso init finishes before plan starts.
Why init runs per project
Each root module owns separate state, usually a distinct key or workspace in the backend. A single init at the repo root cannot configure that. If stacks share one backend configuration, they share state, and a plan for one stack would propose destroying another's resources. Running init per project with explicit backend config keeps every plan scoped to its own state.
Shared modules
A reusable module under infra/modules/ has no backend and no state of its own, so its project.json defines only fmt-check and validate. Changes to the module still trigger plans, but only through the stacks that consume it.
{
"name": "payments",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"root": "infra/stacks/payments",
"targets": {
"fmt-check": {
"executor": "nx:run-commands",
"options": {
"cwd": "infra/stacks/payments",
"command": "terraform fmt -check"
}
},
"validate": {
"executor": "nx:run-commands",
"options": {
"cwd": "infra/stacks/payments",
"commands": [
"terraform init -backend=false -input=false",
"terraform validate"
],
"parallel": false
}
},
"plan": {
"executor": "nx:run-commands",
"options": {
"cwd": "infra/stacks/payments",
"commands": [
"terraform init -input=false -backend-config=backend.hcl",
"terraform plan -input=false -out=tfplan"
],
"parallel": false
}
}
}
}
A project.json for the payments root module: three nx:run-commands targets run in the stack directory, with init using the stack's own backend.hcl before plan; a shared module's project.json keeps only fmt-check and validate.
Making HCL dependencies visible with implicitDependencies and namedInputs
dependency edges you declare by hand. Nx infers project relationships for languages it can parse, and HCL is not one of them. A source = "../../modules/vpc" line, a terraform_remote_state data source, or a -var-file pointing at a shared tfvars file is invisible to the project graph. When one of those files changes, the stack that depends on it is not marked affected and is skipped without any warning. CI stays green while the plan you needed never runs.
The fix is to declare every edge yourself. In a stack's project.json, implicitDependencies should list each shared module the root module sources. It should also list every stack whose outputs it reads through remote state, because a changed output in the upstream stack can change this stack's plan.
Inputs control the cache. Define a terraform named input in nx.json that covers {projectRoot}/**/*.tf, {projectRoot}/**/*.tfvars, and {projectRoot}/.terraform.lock.hcl. Add negated patterns for README.md and docs/ so a documentation edit does not invalidate cached plans. Then add the shared versions or provider file to the inputs of every stack's plan target. Inputs change cache hashes, but affected detection still works by file ownership. For that reason, put shared files inside a small project that every stack lists as an implicit dependency, so a change to them marks the stacks affected.
| Hidden dependency | How to model it in Nx |
|---|---|
Module source path |
implicitDependencies on the module project |
terraform_remote_state read |
implicitDependencies on the producing stack |
| Shared tfvars | Own project + implicitDependencies; file in target inputs |
| Shared versions/provider file | Own project + implicitDependencies; file in every stack's inputs |
.terraform.lock.hcl |
Included in the terraform named input |
# nx.json: one named input for Terraform sources. Docs edits stay out of the hash.
cat > nx.json <<'EOF'
{
"namedInputs": {
"terraform": [
"{projectRoot}/**/*.tf",
"{projectRoot}/**/*.tfvars",
"{projectRoot}/.terraform.lock.hcl",
"!{projectRoot}/**/README.md",
"!{projectRoot}/docs/**/*"
]
}
}
EOF
# Shared versions.tf and common.tfvars live in their own tiny project.
# Owning these files is what lets Nx mark dependents as affected when they change.
echo '{ "name": "tf-shared" }' > shared/project.json
# Stack project: declare the edges Nx cannot see in HCL.
# module-vpc: sourced via source = "../../modules/vpc"
# stack-network: read through terraform_remote_state
# tf-shared: the shared versions and tfvars files
cat > stacks/app/project.json <<'EOF'
{
"name": "stack-app",
"implicitDependencies": ["module-vpc", "stack-network", "tf-shared"],
"targets": {
"plan": {
"command": "terraform plan -var-file=../../shared/common.tfvars",
"options": { "cwd": "stacks/app" },
"inputs": [
"terraform",
"{workspaceRoot}/shared/versions.tf",
"{workspaceRoot}/shared/common.tfvars"
]
}
}
}
EOF
The nx.json named input covers .tf, .tfvars and lock files and excludes docs. The stack's project.json declares its hidden HCL edges in implicitDependencies and adds the shared versions and tfvars files to the plan target's inputs.
Wiring terraform plan ci: base SHAs, caching, artifacts, and summaries
The CI job needs two SHAs, and how you pick them depends on the build type.
- Pull request builds: set
headto the PR commit andbasetogit merge-baseof that commit and the target branch. This limits the diff to what the PR introduces and ignores whatever else has landed onmainsince the branch was cut. - Main-branch builds: set
baseto the commit of the last successfulmainbuild, notHEAD~1. If a build fails, the next one still covers the stacks it skipped.
Most CI checkouts are shallow, so the merge base may not exist locally. Fetch full history, or deepen the fetch until git merge-base succeeds, before calling nx affected -t plan.
Set --parallel low. Each plan refreshes state against provider APIs and takes a state lock. High concurrency triggers rate limiting and lock timeouts that look like real failures. Start with two or three and raise it only if your providers and backend handle more.
Mark plan with cache: false. Its output depends on remote state and live cloud resources that Nx cannot hash. A cache hit would replay a stale plan as if it were current. validate and fmt-check read only local HCL and lock files, so they stay cacheable and are effectively free on reruns.
Have each plan target write tfplan and a terraform show -no-color rendering into a per-project output directory. Upload both as artifacts: the binary for apply, the text for reviewers.
Finally, post a PR comment listing which stacks were planned, with change counts, and which were skipped as unaffected. Reviewers can then confirm that nothing they expected to change was left out.
#!/usr/bin/env bash
set -euo pipefail
# CI provides: IS_PR, PR_HEAD_SHA, TARGET_BRANCH, LAST_GREEN_MAIN_SHA (placeholders)
if [[ "${IS_PR:-false}" == "true" ]]; then
HEAD_SHA="$PR_HEAD_SHA"
git fetch --no-tags origin "$TARGET_BRANCH"
# Shallow clone: deepen until the merge base with the target branch exists locally
until BASE_SHA=$(git merge-base "$HEAD_SHA" "origin/$TARGET_BRANCH" 2>/dev/null); do
[[ "$(git rev-parse --is-shallow-repository)" == "true" ]] || { echo "no merge base" >&2; exit 1; }
git fetch --no-tags --deepen=100 origin
done
else
HEAD_SHA=$(git rev-parse HEAD)
# Diff against the last *successful* main build so failed builds are re-covered
BASE_SHA="$LAST_GREEN_MAIN_SHA"
if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then git fetch --no-tags --unshallow origin; fi
fi
# Low parallelism: each plan refreshes against provider APIs and takes a state lock.
# 'plan' has cache: false in its target config; validate/fmt-check stay cached.
status=0
npx nx affected -t plan --base="$BASE_SHA" --head="$HEAD_SHA" --parallel=2 || status=$?
# Each plan target wrote dist/terraform/<project>/{tfplan,plan.txt}; CI uploads dist/terraform/
npx nx show projects --affected --base="$BASE_SHA" --head="$HEAD_SHA" --withTarget=plan | sort > planned.txt
npx nx show projects --withTarget=plan | sort > all.txt
{
echo "### Terraform plan (${BASE_SHA:0:8}..${HEAD_SHA:0:8})"
echo "**Planned:**"
while read -r p; do
summary=$(grep -m1 -E '^(Plan:|No changes)' "dist/terraform/$p/plan.txt" 2>/dev/null || echo "plan failed")
echo "- \`$p\`: $summary"
done < planned.txt
echo "**Skipped (unaffected):**"
comm -23 all.txt planned.txt | sed 's/^/- /'
} > plan-summary.md # posted as a PR comment by a later CI step
exit "$status"
A CI-agnostic shell outline that picks base/head SHAs per build type, deepens shallow clones until the merge base is found, runs uncached plans with low parallelism, and writes a planned-versus-skipped summary for the PR comment.
Applying on merge and catching what the graph misses
On merge to main, run apply through nx affected with the same base SHA logic used for the PR, so only stacks that were planned get applied. Give the apply target a dependsOn entry of ^apply. Nx will then apply upstream stacks, such as networking, before the dependents that read their outputs. Without that ordering, a dependent stack can apply against outputs that haven't been updated yet.
Saved plan files raise a timing problem. A plan produced on the PR reflects state and code at that moment. If other merges land on main before this one, the plan can be wrong. Terraform rejects a saved plan when the state it was built against has changed, but it doesn't detect every case. Code merged from another branch can still conflict while the state stays identical. The safer policy is to re-plan on the merge commit and apply that fresh plan file in the same job. Treat the PR plan as review material, not the artifact you execute. If you need a human gate, fail the apply when the fresh plan's change counts differ from the reviewed ones.
Scheduled full plans
Affected detection only sees changes inside the repo. Add a nightly job that runs nx run-many across every stack and uses terraform plan -detailed-exitcode. That flag returns exit code 2 when changes are pending, so the job can alert on any stack that isn't clean. A full plan catches drift that affected detection misses:
- Console edits
- Provider default changes
- Unpinned registry modules that published new releases
- Data sources returning new values
- Dependency edges your project graph never declared
When to avoid it and what the alternatives are
The biggest risk is that the setup only works if someone keeps implicitDependencies accurate by hand. Nx does not parse HCL, so a terraform_remote_state reference or a shared local module that nobody declares is invisible to the graph. A slow CI run costs minutes. A missing edge lets a stack go unplanned and then drift unnoticed until someone applies it.
A correct graph has its own problem. Remote-state edges fan out. If most stacks read outputs from the networking stack, a one-line change there marks most of the repo affected. Shared inputs have the same effect. An edit to a provider version file, nx.json, or a lock file in the default inputs invalidates every project.
Nx also brings a Node toolchain and another configuration layer into the repo. With only a handful of stacks, that overhead may cost more than it saves. Base SHA selection is also fragile. Shallow clones may lack the base commit. Force-pushes rewrite it. Merge queues test against a base that differs from the pull request's.
| Approach | How it decides what to plan | When it fits |
|---|---|---|
| Plan every stack on every change | Runs everything | Few stacks and fast plans, where correctness matters more than CI minutes |
| CI path filters per directory | Changed file paths map to jobs | Stacks are independent and shared modules are rare |
| Dedicated Terraform orchestration or wrapper tooling | Reads dependencies from Terraform configuration | Many stacks with dense cross-stack references, where hand-maintained edges become unreliable |
Impact on development cost and engineering time
Illustrative estimate (assumed numbers, not a benchmark)
Assume a repository with N = 30 stacks and an average plan time of P = 4 runner-minutes. Assume 80% of commits touch a single stack, and 20% touch a shared module that reaches k = 10 stacks.
- Full plans per commit: N × P = 30 × 4 = 120 runner-minutes
- Affected plans per commit: P × (0.8 × 1 + 0.2 × k) = 4 × (0.8 + 2) = 11.2 runner-minutes
Reviewer load follows the same formula. Without the plan durations, a reviewer reads 30 plan outputs per commit under full plans and about 2.8 under affected-only plans. That difference in attention often matters more than the runner time, because a single meaningful diff buried among 29 "no changes" outputs is easy to miss.
Savings depend heavily on the type of commit. A change to a root-level namedInputs entry, a provider version constraint, or a widely used module can mark every stack as affected, so that commit saves nothing. Plug in your own commit mix before committing to the approach.
Ongoing costs
- Dependency edges are maintained by hand.
- Changes to
project.jsonneed review as carefully as changes to HCL. - The scheduled full plan still consumes N × P runner-minutes each time it runs.
- Engineers new to Nx have to learn why a stack did or did not appear in the affected set.
Keeping the graph accurate
- Review
implicitDependenciesin the same pull request whenever a modulesourceor aterraform_remote_statereference changes. - Treat any drift the scheduled full plan finds in a stack that was never marked affected as a missing edge, and add that edge.
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 terraform monorepo, 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.