How nx affected decides a Docker image needs rebuilding
nx affected answers one question: which projects own files that changed between a base and head commit? It diffs the two refs, assigns each changed file to the project whose root contains it, then follows the project graph to include every dependent project. Running a target across that set skips everything else. When a target does run, Nx computes a hash from the target's declared inputs and replays a cached result on a match.
Both steps depend on what Nx knows. A docker/base/Dockerfile or a shared scripts/ directory at the repo root usually belongs to no project. Editing it generally marks nothing as affected and changes no hash, unless you declare it as an input, an implicit dependency, or a project of its own.
Docker uses a different model. The build context is whatever directory you pass to docker build, minus what .dockerignore excludes. Layer caching keys on the instruction and on the contents of files that COPY and ADD actually pull in. Nx never sees that list. It sees only the files you declared.
When the two sets diverge, you get one of two failures:
- Stale images: the Dockerfile copies
libs/proto/or uses a base tag that moved, but neither appears in Nx inputs. The image ships with old contents, or Nx replays a cached run. - Needless rebuilds: inputs cover the whole app, including tests and docs that
.dockerignoreexcludes. A README edit triggers an image build.
This guide is for backend and platform engineers running several Dockerized services in one Nx workspace who want image builds to follow real changes.
git diff <base>..<head>
|
v
+-----------------------+
| changed files |
+-----------------------+
| |
v v
+-------------------+ +-------------------------+
| inside a project | | outside every project |
| root (apps/api/) | | docker/base/, scripts/ |
+-------------------+ +-------------------------+
| |
v v
+-------------------+ +-------------------------+
| project graph: | | affects nothing unless |
| owner+dependents | | declared: input, implicit|
+-------------------+ | dependency, own project |
| +-------------------------+
v
+-------------------+ +----------------------------+
| Nx task hash | differ | Docker layer cache keys |
| = declared inputs |<-------->| = instruction + files that |
+-------------------+ | COPY/ADD pull from context |
| | (minus .dockerignore) |
v +----------------------------+
inputs miss COPY'd files or moved base tag --> stale image
inputs include .dockerignored files -------> needless rebuild
Nx decides what to rebuild from file ownership and declared inputs, while Docker caches on the files COPY and ADD actually read, so any gap between the two sets produces stale images or needless rebuilds.
Defining a docker-build target with dependsOn and Dockerfile inputs
The first step is making the Dockerfile part of the project's footprint. Nx decides what is affected by mapping changed files to projects. A Dockerfile at apps/api/Dockerfile belongs to the api project automatically. A Dockerfile elsewhere, such as a shared docker/ folder, does not. For that case, list it in the target's inputs with a workspace-relative path like {workspaceRoot}/docker/api.Dockerfile.
In the example project.json, docker-build uses the nx:run-commands executor to call the Docker CLI: docker build -f apps/api/Dockerfile -t api:local ., run from the workspace root. The target sets dependsOn to ["build"], so Nx compiles the app before invoking Docker. Without this, the image would copy whatever happened to be in dist/apps/api, which on a fresh CI runner is nothing and on a laptop may be stale.
The inputs array lists production, ^production, and the Dockerfile path. Explicit inputs replace the default set rather than adding to it, so if you list only the Dockerfile, source edits stop invalidating the image.
With this configuration, a Dockerfile edit has two effects:
- It marks
apias affected, because the changed file maps to that project. - It changes the computed hash for
docker-build, because the file is a declared input. Any cached result for the old hash no longer matches, so Nx runs the build instead of replaying it.
Caching is only relevant if you have enabled it for this target. Many teams leave image builds uncached and rely on affected detection alone.
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/api/src",
"targets": {
"docker-build": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"inputs": [
"production",
"^production",
"{projectRoot}/Dockerfile"
],
"options": {
"command": "docker build -f apps/api/Dockerfile -t api:local .",
"cwd": "{workspaceRoot}"
}
}
}
}
A project.json for apps/api where docker-build runs the Docker CLI after the app's build target and lists the Dockerfile with production and ^production inputs, so a Dockerfile edit marks api as affected and changes the target hash.
Mirroring the build context with named inputs and .dockerignore
A docker named input in nx.json should list exactly what the Dockerfile COPYs. Nothing more, nothing less. Typical entries are the Dockerfile itself, the project's source and config, and ^docker (or ^production) for dependency projects. Nx does not read .dockerignore, so the two files describe the same context independently. Treat .dockerignore as the source of truth. Every exclusion there, such as tests, fixtures, or local env files, needs a matching ! negation pattern in the named input, and both changes should land in the same review.
| Approach | Rebuilds | Risk |
|---|---|---|
Broad ({projectRoot}/**/*) |
On any project file change, including tests and docs | Wasted image builds |
Narrow (mirrors COPY lines) |
Only when shipped files change | Missed rebuilds if the Dockerfile gains a COPY the input lacks |
Broad inputs fail safe. Narrow inputs are cheaper but drift silently, so update them whenever the Dockerfile changes.
The harder case is a build context at the workspace root, which is common when images need shared libs. Two problems show up:
- Pruned lockfiles. A per-app lockfile generated at build time is what Docker actually sees. If the input points at the root lockfile, any unrelated dependency bump triggers a rebuild. That over-approximates, but it is safe.
- Shared libs. Suppose a Dockerfile copies
libs/wholesale while the input relies on^docker. Nx then only tracks libs that are graph dependencies. Docker can bake in files Nx never hashes, and that divergence goes unnoticed.
Copying only graph-resolved libs keeps both views aligned.
# Keep .dockerignore (source of truth) and the Nx 'docker' input side by side.
# Every exclusion here must have a matching '!' pattern in nx.json.
cat > .dockerignore <<'EOF'
**/*.spec.ts
**/fixtures/**
**/.env*
EOF
# 'docker' lists only what the Dockerfile COPYs, plus ^docker for graph deps.
# Bind the target to it so test or doc edits do not trigger an image rebuild.
cat > nx.json <<'EOF'
{
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"docker": [
"{projectRoot}/Dockerfile",
"{projectRoot}/package.json",
"{projectRoot}/tsconfig*.json",
"{projectRoot}/src/**/*",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/fixtures/**",
"!{projectRoot}/**/.env*",
"^docker"
]
},
"targetDefaults": {
"docker-build": {
"inputs": ["docker"]
}
}
}
EOF
A .dockerignore and an nx.json docker named input kept in step. Each Docker exclusion is mirrored by a ! negation, and ^docker limits dependency hashing to libraries in the project graph.
Shared base images and nx implicit dependencies
Some files that shape an image belong to no app and no library: a shared Dockerfile.base, an entrypoint.sh copied into every image, a hardened nginx.conf, a script that installs OS packages. If these sit loose at the repository root, Nx treats them as global files or ignores them. Neither gives you precise rebuilds.
A better structure is a small project, for example docker-base, that owns these files. Each app's project.json then lists it under implicitDependencies. A change inside docker-base marks that project as affected. Nx then walks the graph and marks every app that declares it as affected too. Being affected only decides whether docker-build runs. To make the cache hash change as well, the app's docker-build inputs must reach the base project's files through a dependency input such as ^docker. The docker named input in docker-base must also match those files. Without that, an affected app can still get a cache hit and reuse a stale image.
The weakness is that implicitDependencies is a static, hand-written claim. Nx cannot parse a FROM line or a COPY --from and check that the declared edge exists. That causes drift in two directions:
- Overbuilding: an app moves to a different base image but keeps the stale entry, so it rebuilds for no reason.
- Missed rebuilds: a new app copies
entrypoint.shwithout declaring the dependency, so it misses rebuilds and ships stale scripts.
Overbuilding only costs CI minutes. A missed rebuild ships the wrong image. A cheap CI check that compares each Dockerfile's FROM and COPY sources with the declared dependencies catches both kinds of drift before they reach a release.
// docker-base/project.json: owns the shared image files
{
"name": "docker-base",
"namedInputs": {
// Every file that shapes the shared base image
"docker": [
"{projectRoot}/Dockerfile.base",
"{projectRoot}/entrypoint.sh",
"{projectRoot}/nginx.conf",
"{projectRoot}/scripts/**/*"
]
},
"targets": {
"docker-build": {
"command": "docker build -f docker-base/Dockerfile.base -t acme/base:local docker-base",
"inputs": ["docker"],
"cache": true
}
}
}
// apps/api/project.json: declares the edge Nx cannot infer from FROM/COPY
{
"name": "api",
"implicitDependencies": ["docker-base"],
"namedInputs": {
"docker": ["{projectRoot}/Dockerfile", "{projectRoot}/src/**/*"]
},
"targets": {
"docker-build": {
"command": "docker build -f apps/api/Dockerfile -t acme/api:local .",
// "^docker" pulls docker-base's files into this hash, so a base change
// forces a cache miss instead of reusing a stale image
"inputs": ["docker", "^docker"],
// Build the base image before the app image that uses it in FROM
"dependsOn": ["^docker-build"],
"cache": true
}
}
}
Two project.json files (Nx accepts comments in them) where implicitDependencies marks the api app as affected when docker-base changes, and the "^docker" input makes that change alter the app's docker-build cache hash.
Detecting base image changes: digests, tracked files and runtime inputs
Even with every FROM and COPY source declared, one input remains invisible. Nx hashes file contents, so a line like FROM node:lts hashes identically whether the registry tag points at last month's image or this morning's rebuild. The bytes in the Dockerfile haven't changed, so nothing is affected. There are three ways to make the base image visible to the hasher.
| Approach | What Nx hashes | Main risk |
|---|---|---|
Digest in the Dockerfile (image@sha256:…) |
The Dockerfile itself | Upstream security patches never arrive unless a bot or scheduled job opens bump PRs |
| Reference in a tracked file (e.g. a shared versions file consumed via build arg) | That file, declared as an input | Dockerfile and file can drift if a project hard-codes its own FROM |
| Runtime or env input resolved at hash time | Command output or variable value | Registry lookups add network calls, can fail or time out, and can yield different hashes on different machines |
Digest pinning is the most deterministic option, because every change becomes a reviewable diff. That determinism works against you if no automated bump process exists, since the pin freezes the image and its known vulnerabilities with it. A tracked file gives you the same determinism and lets one bump fan out to every image that references it. A runtime input that queries the registry is the most current, but it trades reproducibility for freshness. Two developers hashing minutes apart can disagree, and a failed lookup can turn a cache hit into a full rebuild.
None of these approaches reaches inside Docker. When Nx decides to rebuild, Docker's layer cache can still reuse a RUN step that runs curl or installs unpinned packages. The instruction text is unchanged, so Docker serves the old layer. Pin the fetched content too, or bust the cache for those steps.
Running nx affected -t docker-build in CI and verifying the result
Nx can only compute "affected" from the git history it has. Many CI checkouts are shallow by default, so the base commit may not exist locally. Depending on the setup, that either fails the comparison or falls back to a base that treats everything as changed. A base SHA that is wrong but present causes the opposite problem. If you pin it to the previous commit instead of the last successful run on main, a failed pipeline's changes get dropped from the next diff, and images silently skip rebuilding. To avoid both, fetch full history (or at least deep enough to reach the base), then pass the base and head explicitly via --base/--head or the NX_BASE/NX_HEAD environment variables. Derive the base from the last green main build, not HEAD~1.
Verify before you trust the result
Run nx show projects --affected -t docker-build first. It lists what would build without building anything. If the list surprises you, nx show project <name> --json shows the resolved inputs and dependsOn for the target, and nx graph --affected shows which edge pulled a project in. Check this whenever you change named inputs or implicit dependencies.
Should the Docker target be cacheable?
Usually not. A cache hit, particularly a remote one, replays the terminal output and marks the task successful, but no image exists in the runner's local daemon. If your push step depends on that image, it fails. If the registry tag was later deleted or never written, Nx still reports success. Set cache: false on docker-build. Let Docker's layer cache and registry-backed build cache handle reuse, and let Nx handle only the affected selection.
When to use it, when to avoid it, alternatives and impact on cost and engineering time
That split of responsibilities pays off when the workspace has several Dockerized apps and building every image on every commit is expensive. It is a poor fit for a single-image repo. It also buys little when the Docker context is the workspace root and the inputs effectively cover every file. And it is risky for teams that won't keep implicit dependencies and base-image digest bumps current. In those cases, stale inputs cause skipped rebuilds and ship outdated images, which is worse than a slow pipeline.
| Approach | Precision | Ongoing upkeep | Typical failure |
|---|---|---|---|
nx affected -t docker-build |
Per project, follows the dependency graph | Inputs, implicit deps, digests | Missing input skips a needed rebuild |
| Rebuild all, rely on layer cache | Exact, nothing skipped | Low | Cost grows with image count; cache misses on new runners |
| CI path filters | Per directory | Filter lists duplicate the graph | Shared libraries are not detected |
| Build only on merge to main | Coarse | Minimal | Broken images surface after merge |
Illustrative estimate (assumed numbers, not a benchmark).
Runner minutes saved per day = pipelines/day × images × minutes per image × (1 − affected fraction)
With 40 pipelines/day, 8 images, 6 minutes per image and 25% affected:
40 × 8 × 6 × 0.75 = 1,440 runner-minutes/day
On the cost side, assume 2 engineer-days to model the inputs up front and about 1 hour per week for upkeep. Upkeep covers digest bumps, new shared libraries and fixes to .dockerignore drift.
Wall-clock savings are smaller than runner-minute savings if your images already build in parallel. If your affected fraction is close to 1, skip this setup and rebuild everything.
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 dockerfile changes, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.