Why tagging every image on every commit hurts
A common starting point for a monorepo pipeline is simple: on every merge to main, build every service's Docker image and push it with the commit SHA or latest. It works, but it scales badly once you have more than a handful of deployable services.
The costs of retagging everything
Registry noise. Ten services and twenty merges a day produce two hundred new tags, even if most merges touched one service. Image layers may be shared or cached, but the tag list grows, and anyone looking for a meaningful image has to scan past near-identical ones. Retention policies also get harder to write, because you cannot tell which tags matter.
Pointless deploys. If your CD system rolls out when a tag changes, every service redeploys on every merge. Each rollout is a chance for a restart to fail, a health check to flap, or a connection pool to drain at a bad moment, and none of these rollouts delivered new code.
Broken traceability. When payments:abc123 and payments:def456 contain identical code, the tag no longer tells you what changed. During an incident you want a tag to point to a specific set of commits that touched that service, plus a changelog entry.
The alternative
Treat each service image as a release artifact owned by Nx Release. Each service is versioned independently, only when Nx detects it changed, and the Docker tag is derived from that version.
This approach suits backend and platform engineers running several deployable services in one Nx monorepo, where services deploy separately and need tags you can trace back to code.
How Nx decides what changed: the project graph and affected detection
Nx builds a project graph from your workspace: every project, plus the edges between them. Most edges come from static analysis of imports. If orders-api imports from libs/shared-auth, Nx records that dependency without any configuration.
Affected detection compares the working tree against a base ref, such as origin/main or the last release tag. It maps the changed files to the projects that own them and then walks the graph to find every dependent. A one-line fix in shared-auth therefore marks orders-api, billing-api and any other consumer as affected. This is correct behavior, because each of those images now ships different code.
The graph only knows what it can see, and Docker builds read files that no TypeScript import ever touches:
- a shared
docker/node-service.Dockerfile - a base image pinned in a common file
- root configuration like
tsconfig.base.json .envtemplates copied into images
If one of these changes, the rebuilt container behaves differently, yet no project is affected and no version is bumped. The tag stays the same while the contents change, which defeats the purpose of independent versioning.
You can close these gaps in two places:
- Shared projects: wrap shared Docker assets in their own project and list it under
implicitDependenciesin each service'sproject.json. The example below shows this for one service. - Root files: add them to
namedInputsinnx.jsonso that a change to them counts as a change to every project that uses those inputs.
To confirm what Nx sees, use these commands:
nx graphopens an interactive view of the graph.nx show project orders-apilists the project's resolved configuration and inputs.nx show projects --affected --base=origin/mainprints exactly which projects a given diff touches.
# 1. Give the shared Docker assets their own project so Nx can track them.
# Any file under docker/ now belongs to the 'docker-shared' project.
cat > docker/project.json <<'EOF'
{
"name": "docker-shared",
"root": "docker",
"projectType": "library"
}
EOF
# 2. Declare the edge that static import analysis cannot see.
# orders-api's image is built from docker/node-service.Dockerfile,
# so a change there must mark orders-api as affected.
cat > apps/orders-api/project.json <<'EOF'
{
"name": "orders-api",
"root": "apps/orders-api",
"sourceRoot": "apps/orders-api/src",
"projectType": "application",
"implicitDependencies": ["docker-shared"]
}
EOF
# 3. Check what Nx now sees.
# Interactive graph, focused on one service and its dependencies:
nx graph --focus=orders-api
# Resolved configuration, including implicit dependencies and inputs:
nx show project orders-api
# Projects affected relative to the base ref. After editing
# docker/node-service.Dockerfile, orders-api should appear here:
nx show projects --affected --base=origin/main
Wraps shared Docker assets in a 'docker-shared' project, adds it to orders-api's implicitDependencies, and then runs the Nx inspection commands that confirm a Dockerfile change marks the service as affected.
Configuring Nx Release for independent versioning of services
Everything in this section lives under the release key in nx.json. Restrict projects to the deployable services, either by name or by a tag such as type:service, so shared libraries never receive a version or an image tag of their own. Set projectsRelationship to independent so each service gets its own version number. Then use a git tag pattern containing the {projectName} placeholder, for example {projectName}@{version}. Without the project name, two services that reach 1.4.0 would try to create the same tag, and Nx would have no way to find each service's previous release.
Next, decide how the size of each version bump is chosen.
| Approach | How the bump is chosen | What it means for commits |
|---|---|---|
| Conventional commits | Nx reads feat:, fix: and ! markers in commits that touched each project since its last tag |
With squash-merge, only the PR title lands on main. You have to lint PR titles, not individual commits |
| Explicit specifier | A human or CI passes patch, minor or a literal version |
Commit messages are free-form. The bump decision moves into the pipeline or the release command |
Most services are not npm packages. By default Nx reads and writes the version field in package.json. If your version lives in project.json, a VERSION file or a Helm chart, you can replace that default with custom version logic. How you do that depends on your Nx release.
To keep versioning and changelogs but skip registry publishing, run the version and changelog steps without the publish step, or remove publishing from the release flow.
Option names and defaults have changed across Nx major versions, including where the tag pattern and the conventional-commits setting live. Check the documentation for the Nx version you have installed instead of copying configuration from elsewhere.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"release": {
"projects": ["tag:type:service"],
"projectsRelationship": "independent",
"releaseTagPattern": "{projectName}@{version}",
"version": {
"conventionalCommits": true
},
"changelog": {
"projectChangelogs": true,
"workspaceChangelog": false
}
}
}
An Nx 20-era nx.json release block that versions only projects tagged type:service, independently, with per-service git tags. Run nx release --skip-publish to keep versioning and changelogs without publishing, and check key names against your Nx version's docs.
Dry-running nx release version and deriving image tags
Before wiring versioning into CI, run nx release version --dry-run locally against a branch with known changes. The dry run computes the same version bumps as a real run but writes nothing to disk and creates no commits or tags. Add --verbose if the default output is too terse. For each project, check two things: whether it is listed as receiving a bump, and the reason Nx gives. With conventional commits enabled, the reason should trace back to specific commit messages that touched files in that project or its dependencies. If a service you did not expect appears, the cause is usually a shared library it depends on, or a commit scope that is broader than intended. Fix that now, because in CI it would mean a rebuilt and redeployed image.
After a real versioning run, read each bumped project's resolved version from its manifest. You can also use the per-project results that the programmatic release API returns. Build each image once with two tags: the semver version, and the full git commit SHA. Pass both to docker build with repeated -t flags, then docker push each tag. Projects with no bump get no new image.
The semver tag is for people. Registries allow tags to be overwritten, so orders:1.4.0 is immutable only by convention. For deployments, reference the commit SHA tag or, better, the image digest that docker push reports. A digest identifies exact image content. That makes a rollback a redeploy of a known artifact, and lets an audit tie a running container to a specific commit.
#!/usr/bin/env bash
set -euo pipefail
# 1. Preview: computes bumps, writes nothing, creates no commits or tags.
# Review which projects are listed and the commits Nx cites as the reason.
npx nx release version --dry-run --verbose
REGISTRY="registry.example.com/acme"
SERVICES=(orders payments inventory) # projects that ship as images
SHA="$(git rev-parse HEAD)" # immutable tag source
version_of() { node -p "require('./apps/$1/package.json').version"; }
# 2. Record versions before the real run so unchanged services can be skipped.
declare -A BEFORE
for s in "${SERVICES[@]}"; do BEFORE[$s]="$(version_of "$s")"; done
npx nx release version
# 3. Build and push only the services whose version changed.
for s in "${SERVICES[@]}"; do
NEW="$(version_of "$s")"
if [[ "$NEW" == "${BEFORE[$s]}" ]]; then
echo "skip $s (still $NEW)"; continue
fi
IMAGE="$REGISTRY/$s"
# One build, two tags: semver for humans, commit SHA for traceability.
docker build -f "apps/$s/Dockerfile" -t "$IMAGE:$NEW" -t "$IMAGE:$SHA" .
docker push "$IMAGE:$NEW"
docker push "$IMAGE:$SHA"
# Deploy by digest: it pins exact image content, unlike a movable tag.
DIGEST_REF="$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$SHA")"
echo "$s $NEW $SHA $DIGEST_REF" >> release-images.txt
done
A bash script that previews bumps with a dry run, then tags each changed service's image with its resolved semver version and the commit SHA, and records the pushed digest for deployment.
Ordering the CI pipeline: version, build and push, then git tags
Getting that digest-based traceability depends on running the steps in the right order. Version resolution, image push and git tagging hit three separate systems (the workspace, the registry and the git remote), and no transaction spans them. Any step can fail after an earlier one has already taken effect.
The order
- Run
nx release versionwith git commit and tagging disabled. Capture which projects were bumped and their new versions. - For each bumped project, build the image and push it with the version tag. Record the digest.
- Only after every push succeeds, generate changelogs, commit, create the git tags and push them.
Git tags go last because the next run uses them as its baseline. A tag pushed for an image that was never pushed tells Nx the release happened, so the service is never rebuilt.
Partial failures
- Image with no tag: This is the safe failure. The next run resolves the same version, so re-pushing is idempotent. Before building, check whether that tag already exists in the registry, and skip the build if it does.
- Tag with no image: This happens when someone tags manually or reorders the steps. Delete the git tag, then rerun.
- Orphaned images from abandoned runs: Clean these up with a registry retention policy, not with ad-hoc scripts.
Scheduled rebuilds
Base-image and OS patches never change your source code, so affected detection never sees them. Add a scheduled job that rebuilds every deployed service from its released git tag with a fresh base pull. Push the result under a suffixed tag such as 1.4.2-r3, and leave the release version unchanged.
#!/usr/bin/env bash
# Release order: version (no git side effects) -> build/push -> git tags last.
set -euo pipefail
REGISTRY="registry.example.com/acme"
# 1. Bump versions in the working tree only; no commit, no tag yet.
npx nx release version --git-commit=false --git-tag=false
# Bumped projects = apps whose package.json changed in the working tree.
BUMPED=$(git diff --name-only -- 'apps/*/package.json' | cut -d/ -f2)
[ -z "$BUMPED" ] && { echo "nothing to release"; exit 0; }
# 2. Build and push each bumped service; any failure aborts before tagging.
for app in $BUMPED; do
version=$(node -p "require('./apps/$app/package.json').version")
image="$REGISTRY/$app:$version"
# Idempotent retry: skip if a previous run already pushed this version.
if docker manifest inspect "$image" >/dev/null 2>&1; then
echo "skip $image (already in registry)"; continue
fi
docker build -t "$image" -f "apps/$app/Dockerfile" .
docker push "$image"
echo "$app $(docker inspect --format '{{index .RepoDigests 0}}' "$image")" >> digests.txt
done
# 3. Every push succeeded: generate changelogs here, then commit and tag.
git add -A
git commit -m "chore(release): publish"
for app in $BUMPED; do
version=$(node -p "require('./apps/$app/package.json').version")
git tag "$app@$version" # matches the Nx {projectName}@{version} pattern
done
# Push commit and tags together; tags become the next run's baseline.
git push --atomic origin HEAD --tags
# Scheduled patch rebuild (separate job): for each deployed tag, check it out,
# run docker build --pull, push as "$version-r$N"; never touch package.json.
A CI script skeleton that bumps versions without git side effects, builds and pushes only the bumped services with a registry-exists check so retries are idempotent, and creates and pushes the release commit and git tags only after every image push succeeds.
Verifying the result and when to avoid this approach
Checking that only the right images moved
Run two probes after a release. First, make a commit that touches one service only. Then run git tag --list filtered by each project's release tag pattern. The changed service should have one new tag. Every other service's latest tag should be unchanged. Next, list the tags for each repository in your registry. Untouched services should show no new tag, and their newest tag should still match their latest git tag. A new image tag with no matching git tag means the build step ran outside the affected set.
The second probe is a change to a shared library. Every service that depends on it should get a new version, even though no service code changed. To confirm this, compare the list of bumped services against the dependents shown by nx graph. A dependent that did not bump usually means an import Nx cannot see, such as a dynamic path or a file read at runtime. Those services will ship stale code under an old tag.
When not to do this
- Services that always deploy together. A single shared version describes them more honestly than separate versions do.
- A poorly modeled project graph. Affected detection can only be as accurate as the graph it reads. Missing edges cause bumps to be skipped without any error.
- Teams without commit discipline. If version bumps are derived from conventional commits, bad commit messages produce wrong bumps.
- Widely used libraries. If most changes touch a core package, nearly every service bumps anyway. You pay the added complexity and get little selectivity in return.
Independent versioning also creates many more git tags and changelogs. That makes it harder to answer a simple question: which versions are running together right now? If you adopt this approach, record the set of deployed versions somewhere, such as a deploy manifest.
Alternatives and impact on cost and engineering time
Each option trades off traceability, registry growth, deploy noise, and how much tooling your team has to build and maintain.
| Approach | Traceability | Registry growth | Deploy noise | Setup effort | Maintenance burden |
|---|---|---|---|---|---|
| Commit SHA on every build | Exact commit, but no semantic meaning | High: every image, every build | High | Low | Low |
| Single fixed repo version | Shows which versions run together, but per-service changes are unclear | High | High | Low | Low |
| Custom affected-based scripts | Only as good as your scripts | Low | Low | Medium to high | High: you own the tag and bump logic |
| Nx Release independent versioning | Per-service version, git tag and changelog | Low | Low | Medium | Medium: the project graph must stay accurate |
The main development cost of Nx Release is setup: release configuration, commit conventions, and the CI ordering from section 5. After that, the recurring work is keeping the graph accurate. Nx cannot see inputs that it does not track, such as a shared base Dockerfile or a config file that your code reads at runtime. A missed dependency means a service that should have been rebuilt is not. You also need retry-safe handling for non-atomic failures, for example when an image push succeeds but the git tag push fails.
Engineering time is saved in three places. You stop running builds and deploys that change nothing. Nobody writes or maintains custom tag logic. Changelogs are generated instead of written by hand.
Illustrative estimate (assumed numbers, not a benchmark): Assume S = 20 services, C = 3 affected services per merge, M = 15 merges per day, and T = 6 minutes to build and push one image.
Wasted CI minutes per day = (S − C) × M × T = 17 × 15 × 6 = 1,530.
Avoided no-op rollouts per day = (S − C) × M = 255.
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 release docker, 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.