What nx affected does for a Terraform monorepo, and what it cannot see

In this setup, every Terraform root module (the directory where you run terraform plan, usually one per environment and stack) is registered as an Nx project. Reusable child modules become projects too. When you run nx affected -t plan, Nx compares the Git diff between a base and head commit, maps the changed files to the projects that own them, then walks the project graph to add every project that depends on them. Only those projects run plan.

This gives you two benefits. CI time scales with the size of the change rather than the size of the repo. Reviewers also get plan output only for the stacks a PR can actually alter, so a real resource change is easier to spot.

The limitation matters more than the benefit. Nx does not parse HCL. It cannot see any of these relationships:

  • Module sources. A source = "../../modules/network" line does not create a dependency edge.
  • Remote state reads. A terraform_remote_state data source that reads another stack's outputs does not create one either.
  • Shared configuration. Shared provider or backend files outside the project directory are invisible.

Each of these must be declared by hand. Otherwise a change to a shared module produces no plan for the stacks that use it.

  modules/network  (child)      modules/iam  (child)
       |      \                    |
       |       \ implicit          | implicit
       v        v                  v
  prod/network  staging/network  prod/app
       ^                           |
       +----- remote_state --------+
        (prod/app reads network outputs)
Edges below exist in the Nx graph only because they are declared by hand
(implicitDependencies); Nx does not read HCL to find them.

+-----------------+                  +-----------------+
| modules/network |                  | modules/iam     |
| child project   |                  | child project   |
+-----------------+                  +-----------------+
    |          |                              |
    | implicit | implicit                     | implicit
    v          v                              v
+----------+ +-----------------+     +-----------------+
| prod/    | | staging/network |     | prod/app        |
| network  | | root module     |     | root module     |
| root     | |                 |     |                 |
+----------+ +-----------------+     +-----------------+
    |                                         ^
    | implicit: prod/app reads its outputs    |
    | via terraform_remote_state              |
    +-----------------------------------------+

+------------------------------------------------------+
| shared/providers.tf, shared/backend.tf               |
| outside every project dir: invisible to nx affected  |
| unless added to each root project's inputs           |
+------------------------------------------------------+

diff touches modules/network/main.tf
  => plan: prod/network, staging/network, prod/app

Terraform root and child modules as Nx projects. Arrows point from a project to the projects that depend on it. Every module-source and remote-state edge must be declared by hand, and shared config outside project directories must be added to inputs, or nx affected will not detect it.

Laying out the repo and defining fmt, validate and plan targets

Give each root module (the directories you actually run terraform apply against, such as stacks/prod/network, stacks/staging/network and stacks/prod/app) its own project.json. Shared child modules under modules/ get a project.json too, but treat them as library-style projects. They exist so Nx can place them in the graph and lint them. Nobody plans them directly. Tag them differently (for example type:stack and type:module) so CI can target stacks alone.

Define every target with the nx:run-commands executor and set cwd to {projectRoot}. Terraform resolves backends, .terraform directories and relative source paths from the working directory, so a command run from the workspace root plans the wrong thing or fails.

  • fmt runs terraform fmt -check and needs no init, so it is cheap enough to run on modules and stacks alike.
  • validate runs terraform init -input=false -backend=false and then terraform validate. Skipping the backend lets it run on child modules and on CI runners without state credentials.
  • plan exists only on stacks. It runs terraform init -input=false and then terraform plan -input=false -out=tfplan, with parallel: false so init finishes first. -input=false makes a missing variable fail the job rather than hang it waiting for a prompt.

Declare {projectRoot}/tfplan in the plan target's outputs. That tells Nx which artifact the target produces, so a later apply step can consume the exact plan that was reviewed. Saved plans can contain sensitive values, so treat that file as a secret wherever it is stored.

{
  "name": "prod-network",
  "$schema": "../../../node_modules/nx/schemas/project-schema.json",
  "projectType": "application",
  "tags": ["type:stack", "env:prod"],
  "targets": {
    "fmt": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "{projectRoot}",
        "command": "terraform fmt -check -recursive"
      }
    },
    "validate": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "{projectRoot}",
        "parallel": false,
        "commands": [
          "terraform init -input=false -backend=false",
          "terraform validate"
        ]
      }
    },
    "plan": {
      "executor": "nx:run-commands",
      "outputs": ["{projectRoot}/tfplan"],
      "options": {
        "cwd": "{projectRoot}",
        "parallel": false,
        "commands": [
          "terraform init -input=false",
          "terraform plan -input=false -out=tfplan"
        ]
      }
    }
  }
}

A project.json for the stacks/prod/network root module with fmt, validate and plan targets, each run from the project directory, with the saved tfplan declared as the plan target's output.

Declaring the dependencies Nx cannot infer

Nx builds its project graph from the import statements it knows how to parse, and HCL is not among them. A module "network" { source = "../../modules/network" } block creates no edge, so a change to the network module would leave every root module that uses it unaffected. Add each local child module a root module sources to that root module's implicitDependencies in project.json. Keep the list in sync with the source paths, or check it in CI with a script that greps for them.

Cross-stack edges need more judgment. Add one only when stack B reads stack A's outputs through terraform_remote_state, or uses a data source to look up resources that A creates. Two stacks that share an account or a naming convention do not need an edge. Each unnecessary edge makes Nx plan extra stacks on every change.

In nx.json, define a terraform named input that includes {projectRoot}/**/*.tf, {projectRoot}/**/*.tfvars and {projectRoot}/.terraform.lock.hcl, and excludes !{projectRoot}/**/*.md. With that exclusion, documentation edits do not trigger plans. Put files that every stack depends on in sharedGlobals: shared provider version constraints, backend configuration and the CI scripts that run Terraform.

Nx only sees files in the repo, so it cannot tell when a new version of a registry module or provider is published. That kind of upstream change counts only when a version constraint or the lock file changes. Pin both.

Dependency type How to model it in Nx
Local child module (source = "../...") implicitDependencies on the root module
terraform_remote_state reading outputs implicitDependencies, only if outputs are consumed
Data-source lookup of another stack's resources implicitDependencies, only if those resources come from that stack
Shared provider constraints, backend config, CI scripts sharedGlobals named input
Registry module or provider Pinned version plus .terraform.lock.hcl in inputs
README and docs Excluded with !{projectRoot}/**/*.md
{
  "namedInputs": {
    "sharedGlobals": [
      "{workspaceRoot}/terraform/shared/versions.tf",
      "{workspaceRoot}/terraform/shared/backend.hcl",
      "{workspaceRoot}/scripts/ci/terraform-*.sh"
    ],
    "terraform": [
      "{projectRoot}/**/*.tf",
      "{projectRoot}/**/*.tfvars",
      "{projectRoot}/.terraform.lock.hcl",
      "!{projectRoot}/**/*.md",
      "sharedGlobals"
    ],
    "default": ["terraform"]
  },
  "targetDefaults": {
    "plan": {
      "inputs": ["terraform", "^terraform"],
      "dependsOn": ["^plan"]
    }
  }
}

An nx.json sketch where a plan target's inputs are Terraform sources, tfvars, the lock file, and shared globals (provider constraints, backend config, CI scripts), excluding Markdown docs.

Running nx affected -t plan in CI with the right base and head

On a pull request, the base should be the merge base between the branch and main, not the tip of main. If you compare against the current main tip, files changed on main since the branch was cut count as part of the PR's diff. That affects projects the PR never touched. On main, set the base to the last commit that deployed successfully, not HEAD~1. If a deploy fails and the next merge lands, HEAD~1 only covers the newest commit. Any stack changed in the failed commit drops out of the affected set, and nothing reports the gap.

Shallow clones cause a similar silent failure. With a depth-one checkout, Git cannot find the merge base or the recorded deploy SHA. Fetch enough history to reach both commits before you pass --base and --head.

Ordering and parallelism

Set dependsOn on plan to ^plan. With that in place, a network stack's plan finishes before the plan of any stack that reads its outputs.

Keep --parallel low. Each plan acquires a state lock. Several plans running against one backend, or against the same provider account, can fail with lock timeouts or hit API rate limits. The error output often doesn't make that cause obvious.

From plan to apply

Have each project's plan target run terraform plan -out to save the plan file. Then render that file with terraform show and post the result to the PR as a separate comment per project, so reviewers can read one stack at a time.

Store the binary plan file as an artifact keyed to the commit SHA. The apply job should run terraform apply on that saved file rather than planning again. If it plans again, it can apply changes that nobody reviewed.

#!/usr/bin/env bash
set -euo pipefail

# A depth-one checkout cannot find the merge base or the last deployed commit,
# so make sure full history is available before computing the affected set.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --no-tags --unshallow origin
fi
git fetch --no-tags origin main

HEAD_SHA="$(git rev-parse HEAD)"

if [ "${CI_EVENT:-}" = "pull_request" ]; then
  # Compare against the point where the branch left main, not main's current tip.
  # Otherwise, commits that landed on main later are counted as part of this PR.
  BASE_SHA="$(git merge-base origin/main HEAD)"
else
  # On main, compare against the last commit that deployed successfully, not HEAD~1.
  # This keeps stacks from a failed deploy in the affected set.
  # LAST_DEPLOYED_SHA is recorded by the apply job after a successful run.
  BASE_SHA="${LAST_DEPLOYED_SHA:?LAST_DEPLOYED_SHA must be set}"
fi

# Fail loudly if the base commit is missing locally, instead of planning the wrong set.
git cat-file -e "${BASE_SHA}^{commit}"

echo "Planning affected stacks for ${BASE_SHA}..${HEAD_SHA}"

# The plan target sets dependsOn ["^plan"], so upstream stacks plan first.
# Low parallelism avoids state lock contention and provider API rate limits.
npx nx affected -t plan --base="$BASE_SHA" --head="$HEAD_SHA" --parallel=2

A CI script that fetches enough Git history, uses the merge base on pull requests and the last successfully deployed commit on main, checks that the base commit exists, and runs nx affected -t plan with limited parallelism.

Caching and drift: why plan should not be trusted from cache

Nx's cache creates a related risk. Nx computes a cache key from a target's declared inputs, such as source files, configuration and selected environment variables, and replays the stored output when the key matches. A Terraform plan also depends on things Nx never hashes: the current remote state, the real resources returned by cloud APIs and the provider's behaviour. If the .tf files are unchanged, Nx sees the same key and can replay last week's plan output for a reviewer to approve, even though the infrastructure has since changed. Set cache: false on the plan target and keep caching on for targets that depend only on files in the repository.

Target Cache Reasoning
fmt Yes Output depends only on the .tf files and the Terraform version.
validate Yes Checks syntax and internal consistency against providers. Hash the lock file and provider versions as inputs.
plan No The result depends on live state and cloud APIs. A replayed plan can be stale.
apply No Changes real infrastructure. Replaying it would mean skipping work that must run.

The same blind spot applies to affected detection. nx affected compares file changes between the base and head commits, so it misses changes made outside Git:

  • resources edited in a cloud console
  • remote state changed by another pipeline
  • a provider changing its defaults
  • credentials that have expired

A stack nobody has committed to can drift for months without any job planning it.

To catch this, add a scheduled job that runs nx run-many -t plan across all projects. Run it nightly or weekly, depending on how often your infrastructure changes. Have it fail or open an alert when any plan reports changes, so someone reviews the drift before the next routine change runs into it.

Who should use it, when it pays off, and when to avoid it

This setup fits platform teams with many root modules, where a full terraform plan across every stack takes long enough to slow reviews, or produces so much output that reviewers stop reading it. It also fits teams already running Nx for application code. They have the Node toolchain, the CI conventions and the mental model, so adding Terraform projects costs little.

Skip it when the repo has a handful of stacks and planning all of them finishes in a few minutes. Skip it if your team won't add Node to an otherwise Go- or Python-only toolchain. Also check how connected the graph is. If most stacks read each other through terraform_remote_state, nearly every change marks nearly everything affected, and you pay for Nx without skipping much.

The main risk is a missing implicitDependency. If Nx doesn't know stack B depends on module A, a change to A never plans B, and CI shows green. Planning everything is slow, but it never hides a broken stack. Selective planning can. Treat dependency declarations as code that needs review, and keep the scheduled full plan from the previous section as your backstop.

Approach Strengths Weaknesses
Plan all stacks Simple, nothing is missed Slow and noisy as stacks grow
Path-based CI filters per directory No extra tooling Misses shared-module changes unless every path is listed by hand
Custom git-diff scripts Fully tailored You maintain the dependency logic yourself
nx affected Graph-aware, cacheable, consistent with app code Requires Node and accurate implicit dependencies

Impact on cost and engineering time

Scoping plans saves the most where the cost scales with stack count: CI minutes, the time a reviewer spends reading plan output, and the time an author waits for a green check. The formula is simple enough to run with your own numbers.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 stacks, an average plan of 3 minutes, 4 stacks affected per PR (10%), 200 PRs a month, and a nightly full drift plan.

  • Full planning: 200 PRs × 40 stacks × 3 min = 24,000 runner-minutes/month
  • Affected planning: 200 × 4 × 3 = 2,400, plus drift at 30 nights × 40 × 3 = 3,600, for a total of 6,000 runner-minutes/month

General form: PRs × stacks × fraction_affected × plan_minutes + drift_runs × stacks × plan_minutes.

The drift term matters. With few PRs or a high affected fraction, it can erase most of the savings.

Wall-clock gains are smaller than the minute counts suggest. You cannot run 40 plans in parallel to compensate for full planning, because state-backend locks serialize work on shared state and provider API rate limits throttle concurrent refreshes. Fewer plans per PR means less contention for both, and in practice this is often where the wait-time improvement comes from.

Against this, count the ongoing costs:

  • Every implicit dependency you declared by hand is a maintenance item that fails silently when it goes stale.
  • New stacks need project configuration.
  • Someone has to own the drift job and act on its output.

If your team adds stacks or cross-stack references often, budget review time for the graph itself, not just the code it describes.

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