What is an nx affected deploy, and how does it work?
An nx affected deploy is a pipeline pattern where Nx decides which services might need deploying, and the rest of your CD system decides which ones will. Nx diffs two commits, a base SHA and a head SHA, to get the set of changed files. It maps each file to the project that owns it, checks the change against that project's configured inputs, then walks the project graph to add every project that depends on a touched project. The result is the affected set.
Running nx show projects --affected --json with explicit --base and --head values returns that set as a JSON array of project names. You can filter it to deployable projects with flags such as --type=app or --projects patterns. The output contains no environment state and no judgment about whether a deploy is safe. It is a list of deploy candidates computed from source changes alone.
That distinction drives the pipeline design. Downstream stages consume the list, apply policy, and record outcomes:
deploy-state store → base SHA (last successful deploy per environment) → nx show projects --affected → JSON list of candidates → matrix fan-out → per-service deploy jobs → state update (write new SHA back to the store)
The loop closes on the state store. The base SHA should be the commit that was last deployed successfully, not the previous commit on the branch. If the base comes from the branch instead, a failed or skipped deploy drops out of the next diff, and the change it carried never reaches production.
+-------------------------+ +------------------------------+
| Deploy-state store | | Git: head SHA (this commit) |
| last good SHA per env | +--------------+---------------+
+-----------+-------------+ |
| read base SHA | head SHA
v v
+------------------------------------------------------------------+
| nx show projects --affected --base=<base> --head=<head> --json |
| changed files -> owning project -> inputs -> project graph |
+---------------------------------+--------------------------------+
v
+---------------------------------------+
| JSON list of deploy candidates |
| ["api","web","auth"] |
+-------------------+-------------------+
| matrix fan-out
+--------------------+--------------------+
v v v
+-------------+ +-------------+ +-------------+
| deploy: api | | deploy: web | | deploy: auth|
| apply policy| | apply policy| | apply policy|
+------+------+ +------+------+ +------+------+
+--------------------+--------------------+
| on success: write deployed SHA
v
+-----------------------------------------+
| record outcome; advance env base SHA |
| (loops back to deploy-state store) |
+-----------------------------------------+
Nx turns the diff between the last successfully deployed SHA and the head SHA into a JSON list of deploy candidates. Downstream stages fan that list out into per-service deploy jobs, apply policy in each job, and advance the base SHA in the state store only after a deploy succeeds.
Why is the base SHA the whole game for selective deploys?
Consider a run where commit A changes billing-api, the deploy job fails, and commit B touches only web. With HEAD~1 as the base, the next pipeline diffs B against A, sees only web, and ships it. billing-api stays on its old build until someone edits it again. Nothing errors and nothing alerts. Cancelled runs, [skip ci] commits and squashed merges that land several commits at once all cause the same silent gap.
The fix is to anchor the base to the last commit that deployed successfully to that specific environment. Staging and production advance at different speeds, so each environment needs its own pointer. Record it only after the deploy succeeds, never before. Common places to store it are a moving git tag such as deployed/prod, a CI artifact from the last green run, or a row in a deployment record your tooling already keeps.
| Approach | Setup cost | Failure mode |
|---|---|---|
HEAD~1 / previous main commit |
None | Silently drops services after failed or skipped runs |
| Per-environment git tag | Low; needs push rights for tags | Tag not updated, or force-moved by mistake |
| CI artifact or deployment record | Moderate; extra storage and lookup | Store unavailable, so pipeline blocks or falls back |
Whichever store you use, the base commit must exist in the checkout. A shallow clone usually lacks it, so either fetch full history or fetch the base SHA explicitly. Otherwise the comparison fails, or it falls back and marks everything as affected.
Stored state is extra infrastructure and a new thing that can break. Previous-commit diffing has no such dependency, but it loses services without telling you.
#!/usr/bin/env bash
# Deploy only projects affected since the last successful deploy to ENV.
set -euo pipefail
ENV="${DEPLOY_ENV:-prod}" # staging and prod each get their own pointer
TAG="deployed/${ENV}"
# The base commit must exist locally, so a shallow clone is not enough.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow origin
fi
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" || true
if git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then
BASE="$(git rev-parse "refs/tags/${TAG}^{commit}")"
echo "Base for ${ENV}: ${BASE} (last successful deploy)"
# This includes every commit since that deploy, even ones whose runs
# failed, were cancelled or were skipped.
npx nx affected -t deploy --base="${BASE}" --head=HEAD
else
# No record yet, e.g. a first deploy. Deploy everything rather than
# guessing a base and risking a silent gap.
echo "No ${TAG} tag found; deploying all projects"
npx nx run-many -t deploy
fi
# Move the pointer only after every deploy succeeded. With set -e, any
# failure above exits first, so the next run retries from the old base.
git tag -f "${TAG}" HEAD
git push -f origin "refs/tags/${TAG}"
A CI deploy step that uses a per-environment git tag as the Nx affected base, unshallows the clone so the base commit is present, and moves the tag only after the deploy succeeds.
How do you implement nx affected deploy in a CD pipeline?
Resolve both SHAs before Nx runs, then pass them explicitly with nx show projects --affected --base=<sha> --head=<sha>. Explicit values remove any dependence on whatever the runner's checkout or environment defaults happen to be. Narrow the result to deployable projects. Filter by project type (--type app), by requiring a deploy target (--withTarget deploy), or by a tag such as deployable. Requiring the target is the safest of the three, because a project with nothing to run cannot end up in the list. Add --json so the next step reads a JSON array rather than scraping terminal output that may change between Nx releases.
Before any deploy runs, add a dry-run step that prints the project list, the base SHA and the head SHA to the pipeline log. When a service unexpectedly does or doesn't ship, that log line is the first thing anyone checks.
Feed the array into a CI matrix or dynamically generated jobs, with one job per service. The alternative is a single job that runs nx affected -t deploy.
| Concern | One job per service | Single nx affected -t deploy job |
|---|---|---|
| Visibility | Each service has its own status and logs | One interleaved log |
| Retry cost | Rerun only the failed service | Rerun everything, or script around it |
| Concurrency and approvals | Per-service gates and limits | All-or-nothing |
| Setup effort | Higher | Minimal |
Mark deploy targets as non-cacheable in your target defaults. On a cache hit, Nx replays the stored output and reports success without executing anything. For a build, that is the intended behavior. For a deploy, it means a green pipeline that shipped nothing.
#!/usr/bin/env bash
# Compute the deployable affected projects and emit them as JSON for a CI matrix.
set -euo pipefail
# Resolve both SHAs before Nx runs. Prefer values injected by the pipeline
# (e.g. the last successfully deployed commit); fall back to the merge base.
BASE_SHA="${BASE_SHA:-$(git merge-base origin/main HEAD)}"
HEAD_SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
# Pass SHAs explicitly, keep only apps that define a deploy target,
# and ask for JSON instead of scraping human-readable output.
npx nx show projects --affected \
--base="$BASE_SHA" \
--head="$HEAD_SHA" \
--type=app \
--withTarget=deploy \
--json > deploy-projects.json
# Dry run: record exactly what will ship and why, before any deploy job starts.
echo "base=$BASE_SHA"
echo "head=$HEAD_SHA"
echo "projects=$(cat deploy-projects.json)"
# The CI system reads deploy-projects.json (a JSON array of project names)
# and fans out one deploy job per entry. Each job then runs, for example:
# npx nx run <project>:deploy --skip-nx-cache
#
# In nx.json, keep deploy out of the cache so a hit can never fake a deploy:
# "targetDefaults": { "deploy": { "cache": false } }
A CI-agnostic script that resolves base and head SHAs, lists affected apps with a deploy target as JSON, logs a dry-run summary for a matrix to consume, and notes how to keep deploy targets uncached.
How do you model deployability and hidden dependencies in the project graph?
Treat deployability as an explicit property of the graph. Give every deployable service its own deploy target and run nx affected against that target alone. Libraries, tooling packages and test utilities have no deploy target, so they never appear in the deploy list, even when they are affected. That keeps "is this affected?" separate from "can this be shipped?"
The harder problem is the dependencies that imports don't show. Nx builds the graph from source imports, so it cannot see that three services read the same base config, build from a shared Dockerfile, or depend on a Helm chart in an infra/ directory. Files inside a project's root already belong to that project. Files outside every project root are the risk. You have two fixes:
- Move shared infrastructure into its own project and list that project in each consumer's
implicitDependencies. - Add the files to the deploy target's inputs through a named input that references workspace-root paths.
This is a calibration problem. If the inputs are too broad, for example the whole infra/ tree or every env file, most merges become full deploys and selective deploys stop paying for themselves. If the inputs are too narrow, a changed config ships nowhere. That is the worse failure, because nothing alerts you to a missed deploy.
Test the wiring on purpose. On a branch, make one change in each of these places:
- a shared library
- the shared Dockerfile
- an env template
After each change, run nx show projects --affected against your real base and confirm that every downstream service appears. Then change a single service and confirm it is the only one listed.
#!/usr/bin/env bash
# Probe the project graph on a throwaway branch: change one hidden dependency at a time
# and print which deployable services Nx reports as affected.
set -euo pipefail
BASE="origin/main" # use the same base ref your CD pipeline uses
git switch -c probe/affected-deploy
probe() {
local file="$1" comment="$2"
echo "== change in $file"
echo "$comment probe" >> "$file"
git commit -qam "probe: $file"
# --withTarget=deploy limits the list to shippable services, the same filter the pipeline uses
npx nx show projects --affected --base="$BASE" --head=HEAD --withTarget=deploy
git reset -q --hard HEAD~1 # undo the probe before the next one
}
# Each of these should list EVERY service that consumes the file.
# An empty or short list means a missing implicitDependency or named input.
probe libs/shared-auth/src/index.ts "//"
probe docker/service.Dockerfile "#"
probe config/.env.template "#"
# Control case: this should list orders-api and nothing else.
# A longer list means the inputs are too broad.
probe apps/orders-api/src/main.ts "//"
git switch -q -
git branch -D probe/affected-deploy
A throwaway-branch script that changes a shared library, the shared Dockerfile, an env template and one service in turn, then lists the affected projects that have a deploy target, so you can see whether the graph's inputs are too narrow or too broad.
How do you handle promotion, ordering, rollbacks and overrides?
Track what each environment actually runs
Store the last successfully deployed commit SHA for every service in every environment, written only after the deploy reports healthy. The obvious place is a small key-value record keyed by service and environment, or a tag on the deployed artifact. That record becomes the true base for the next deploy. It is more reliable than "last green CI run", because a deploy can fail halfway and leave services at different commits.
Promotion should not recompute anything. When staging passes, production receives the exact list of services and artifact versions that staging validated. If you rerun affected logic against production's base, you can pick up commits that merged after staging was tested and ship code nobody verified.
Encode runtime order yourself
The project graph describes build and import relationships. It does not describe deploy order. A database migration must run before the services that read the new column, and an API has to be live before clients call its new endpoint. Neither fact appears in the graph. Declare deploy phases explicitly, either as tags on projects or as a short ordered list in pipeline config. Then intersect each phase with the affected set and run the phases in sequence.
Keep escape hatches
- Add a manual pipeline input for "deploy all" or "deploy these projects" that skips affected calculation.
- Schedule a periodic full redeploy to catch drift from manual changes, expired config, or missed dependencies.
- Make rollback redeploy a known prior artifact per service. It should never route through affected logic.
When should you avoid selective deploys, and what are the alternatives?
Selective deploys only see what the project graph sees. Some changes never touch a tracked file, so they never trigger a deploy unless you model them:
- External config. Feature flags, parameter stores and environment variables managed outside the repo.
- Secrets rotation. A rotated credential that running pods only read at startup.
- Base image updates. A patched OS layer or runtime that every service should pick up.
- Runtime-only coupling. Two services that share a queue schema or an HTTP contract with no import between them.
There is also an operational cost. When each service runs a different commit SHA, "what is in production?" no longer has a single answer. Release notes have to be assembled per service. Debugging a cross-service bug means first working out which SHAs are actually talking to each other. Rollback reasoning gets harder too, because no single commit represents a known-good state for the whole system.
| Approach | Strength | Weakness |
|---|---|---|
| Full snapshot deploy on every merge | One SHA everywhere, simple rollback | Slow, and redeploys unchanged services |
| Per-service path filters in CI | Easy to start | Misses shared-library changes, drifts from reality |
| Separate repositories | Clear ownership boundaries | Cross-repo changes and version pinning overhead |
| Nx affected with stored deploy state | Graph-aware, compares against what is actually deployed | Needs state storage and the blind spots modeled |
Selective deploys pay off in monorepos with many independently deployable services, where full deploys are slow or risky. If you have three services and deploys take minutes, deploy everything. You keep a single SHA in production and skip the bookkeeping.
How does nx affected deploy change development cost and engineering time?
The pattern trades pipeline compute for ongoing engineering work. Weigh the two directly before committing.
| Full deploy | Selective deploy | |
|---|---|---|
| Pipeline time per merge | Grows with service count | Grows with services affected |
| Blast radius per merge | Every service | Only affected services |
| Retry scope | Whole pipeline | One failed matrix job |
| Deploy state to maintain | None | Last-deployed SHA per environment |
| Orchestration | One job | Dynamic matrix built from affected output |
| Ongoing tuning | None | Project inputs and implicit dependencies |
| Drift risk | Low | Requires periodic full redeploys |
The savings show up on every merge. The costs mostly land on the platform team: someone has to own the base-SHA tracking, debug a matrix that came out empty, and tighten inputs after a missed dependency causes a stale deploy.
Illustrative estimate (assumed numbers, not a benchmark)
Assumed inputs:
- N = 20 services
- t = 6 deploy-minutes per service
- k = 3 services affected per merge, on average
- o = 2 minutes of overhead to compute affected projects and build the matrix
- One full redeploy per week, spread across M = 40 merges per week
The formulas:
- Full deploy: N × t = 20 × 6 = 120 deploy-minutes per merge
- Selective deploy: (k × t) + o + (N × t) / M = 18 + 2 + 3 = 23 deploy-minutes per merge
Plug in your own values. If k approaches N, or the overhead o is large, the gap closes. At that point the engineering time spent maintaining deploy state is no longer paid back by the compute you save.
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 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.