What independent versioning in Nx Release is and how it differs from fixed
Nx Release reads its configuration from the release section of nx.json. The projectsRelationship property decides whether the projects being released share one version or each carry their own. It accepts two values: fixed and independent. The same property can also be set on individual release groups, which later sections cover.
With fixed, every project in the group moves together. If one library gets a feature, all of them get the new version number, and a single Git tag marks the release. With independent, Nx works out a new version for each project separately. Tags use a per-project pattern that includes the project name, such as {projectName}@{version}, so billing-client@2.4.0 and auth-core@0.9.1 can sit in the same history without conflict.
| Aspect | Fixed | Independent |
|---|---|---|
| Version number | One shared version for the group | One version per project |
| Git tag | One tag per release | One tag per released project, including its name |
| Unchanged projects | Bumped anyway | Left at their current version |
| Changelog | Typically one for the whole group | Typically one per project |
Backend workspaces often hold libraries that have little to do with each other: a queue client, a logging wrapper, an ORM helper. Under fixed versioning, a patch to the logging wrapper publishes a "new" queue client with identical code. Consumers then see upgrade noise, and the semver signal loses its meaning. Independent versioning keeps each version number tied to real changes in that package.
Nx has changed release options and defaults across versions. Check the documentation for your installed version before you copy configuration.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"release": {
"projects": ["packages/*"],
"projectsRelationship": "independent",
"changelog": {
"projectChangelogs": true
}
}
}
A minimal nx.json release section that versions each backend package independently and writes a changelog per project. Defaults such as the tag pattern vary by Nx version, so check the docs for yours.
Who should use it, when it fits, and when to avoid it
Independent versioning fits platform teams that publish several backend libraries or SDKs from one workspace, where each package has its own consumers and its own pace of change. A database client used by many services, an internal auth SDK used by a few, and a logging helper that rarely changes should not have to ship new versions together. Independent versions let a consumer upgrade the auth SDK without also picking up an unrelated bump to the database client.
When it fits
- Packages have distinct public APIs and can be upgraded separately by consumers.
- Different packages change at clearly different rates.
- Contributors write commits, or version plans, that state what changed and how severe the change is, so bumps can be derived reliably.
When to avoid it
- Tightly coupled packages. If packages are always upgraded together, independent versions only make consumers check a compatibility matrix. A fixed group is easier for them.
- Small teams without commit discipline. If commit messages do not describe the change accurately, the derived semver bumps will be wrong.
- Services that are deployed, not published. Nx Release handles versioning, changelogs and package publishing. It does not deploy anything. A deployed service still needs its own mapping from a version or tag to the image or artifact that is actually running.
The main trade-off is that more tags, changelogs and published versions mean more compatibility questions for consumers. Someone has to answer which versions of which packages work together. Independent versioning moves that work from the release process to the people consuming the packages.
Designing release groups and choosing how bumps are decided
Release groups are where you encode which packages move together. Each group in nx.json takes a projects list and a projectsRelationship. Put tightly coupled packages, such as an API client and its generated types, in a fixed group so they always share a version and consumers never have to match them by hand. Put unrelated libraries, like a logging wrapper and a queue adapter, in an independent group. Each project should belong to exactly one group. Overlapping membership makes it unclear which policy decides a bump, and Nx rejects the configuration anyway. Resist one group per package as well. Every extra group is another rule a new engineer has to learn before they can safely cut a release.
Next, decide where the bump itself comes from:
| Bump source | What it demands | How it fails quietly |
|---|---|---|
Specifier passed to nx release version (explicit or interactive prompt) |
A person who knows what changed, at release time | The releaser guesses, and a breaking change ships as a patch |
| Conventional commits | Commit-message discipline enforced in review or by a linter | A breaking change labelled fix: without ! becomes a patch, and a behaviour change labelled chore: produces no bump at all |
Version plans (nx release plan) |
A plan file committed with the change and reviewed like code | A forgotten plan file leaves the change unreleased |
Version plans put the bump decision into the pull request, where reviewers already look. Commit messages are rarely reviewed with the same care.
// nx.json (Nx parses this file as JSONC, so comments are allowed)
{
"release": {
// Bumps come from plan files created with `nx release plan`,
// committed with the change and reviewed in the pull request
"versionPlans": true,
"groups": {
// The client and its generated types always share one version
"api-client": {
"projects": ["api-client", "api-types"],
"projectsRelationship": "fixed"
},
// Unrelated libraries each get their own version
"infra-libs": {
"projects": ["logging", "queue-adapter"],
"projectsRelationship": "independent"
}
}
}
}
An nx.json with two release groups: a fixed group for a client and its generated types, and an independent group for unrelated libraries. Each project is in exactly one group, and bumps come from version plans.
How dependency-aware bumps propagate to dependents
When a library is versioned, Nx Release finds every workspace project that depends on it and rewrites the dependency range in that project's package.json to point at the new version. That rewrite keeps the published graph consistent. Without it, a dependent's manifest would still declare the old range. Consumers who install the dependent would then resolve the older internal release, or a mix of both if their lockfile allows it. The fix you just shipped would never reach them through that path.
Updating a manifest is a source change, so Nx can also bump the dependent itself, usually as a patch, and publish it. This behaviour is configurable through the updateDependents setting. Where that setting lives varies between Nx versions, so check the documentation for yours. Bumping dependents is the safe default for packages consumed outside the workspace. The cost is release noise: changelogs that say only "updated dependencies," and downstream teams evaluating upgrades that contain no behaviour change. Disabling it is reasonable when every consumer lives in the same workspace and resolves through local links. In that case the published dependent ranges never matter.
Illustrative scenario (not captured tool output)
The workspace contains three packages:
loggingat 1.4.0db-clientat 2.1.0service-sdkat 3.0.5, which depends on both
A feature change lands in db-client only.
db-clientmoves to 2.2.0 and is taggeddb-client@2.2.0.- The
db-clientrange inservice-sdk/package.jsonis updated to 2.2.0, keeping its existing range prefix. - With dependent bumps enabled,
service-sdkbecomes 3.0.6 and is taggedservice-sdk@3.0.6. loggingstays at 1.4.0. It gets no tag and no publish.
# Illustrative scenario only, not captured Nx output.
# Only db-client has a functional change.
# service-sdk/package.json BEFORE the release
{
"name": "service-sdk",
"version": "3.0.5",
"dependencies": {
"logging": "^1.4.0",
"db-client": "^2.1.0"
}
}
# service-sdk/package.json AFTER the release (dependent bumps enabled)
{
"name": "service-sdk",
"version": "3.0.6", # patch bump caused only by the manifest change
"dependencies": {
"logging": "^1.4.0", # untouched: logging did not change
"db-client": "^2.2.0" # rewritten to the new version, "^" prefix kept
}
}
# Resulting versions and git tags
# db-client 2.1.0 -> 2.2.0 tag: db-client@2.2.0
# service-sdk 3.0.5 -> 3.0.6 tag: service-sdk@3.0.6
# logging 1.4.0 (no tag, not published)
# If the range were NOT rewritten, the published service-sdk would still
# declare "db-client": "^2.1.0", so an install of service-sdk is not
# required to pull in 2.2.0. Under a lockfile already pinned to 2.1.x,
# consumers keep the old db-client.
An illustrative before-and-after view of service-sdk's manifest when only db-client changes, with the resulting versions and tags for all three packages.
Running version, changelog and publish with dry runs
Running nx release with no subcommand executes versioning, changelog generation and publishing in sequence. That is convenient locally, but in CI the three subcommands are easier to reason about separately: nx release version writes new versions to each project's manifest and updates dependent ranges, nx release changelog renders entries and creates tags, and nx release publish pushes packages to the registry. Splitting them lets you put an approval gate or an artifact build between versioning and publishing, and rerun a failed publish without bumping again.
Add --dry-run to every phase, including the combined command. A dry run prints the version each project would receive, a diff of every manifest it would touch, the rendered changelog text and the tags it would create. It writes nothing and pushes nothing. For publish, the dry run is passed through to the package manager, so you see what would be packed without uploading it.
Make the dry run a review artifact
Any pull request that changes release groups, the projects list or version settings should include the dry-run output in its description. A config change that silently pulls a service into the wrong group shows up as an unexpected bump in that output. Reviewers would not catch it from the nx.json diff alone.
Project changelogs versus a workspace changelog
With independent versioning, per-project changelogs (projectChangelogs) usually serve consumers better. Each package gets a history that matches its own version line. A single workspace changelog mixes unrelated version numbers and is mainly useful as an internal audit trail.
Nx assigns a commit to a project based on the files the commit changed. The conventional-commit scope is what readers see in the entry. If a commit is scoped auth but touches service-sdk, it appears in the SDK changelog with a misleading label. Use scopes that match Nx project names exactly. Lint for this in commit hooks. Keep commits to one project where possible, so each entry lands in exactly one package's history.
#!/usr/bin/env bash
# Preview every release phase without writing files, creating tags or publishing.
# Run this on any PR that touches release config in nx.json and paste the
# output into the PR description for review.
set -euo pipefail
OUT=release-dry-run.txt
: > "$OUT"
# 1. Combined command: version + changelog + publish, all simulated.
nx release --dry-run | tee -a "$OUT"
# 2. The same phases run separately, as CI would run them.
# Version: shows the bump for each project and a diff of each manifest,
# including updated ranges in dependent packages.
nx release version --dry-run | tee -a "$OUT"
# Changelog: shows rendered per-project entries and the tags it would create.
nx release changelog --dry-run | tee -a "$OUT"
# Publish: the dry run is passed to the package manager, so it packs
# but does not upload.
nx release publish --dry-run | tee -a "$OUT"
# Narrow the preview to the services a config change affects.
# Project names here are placeholders for your own.
nx release version --dry-run --projects=orders-api,billing-worker | tee -a "$OUT"
echo "Dry-run output written to $OUT"
Running nx release as one command and as separate version, changelog and publish subcommands, each with --dry-run, and saving the output for pull request review.
Git tags, CI history and safe package publishing
Set releaseTagPattern to include the project name, such as {projectName}@{version}, so each package's tags are separate and Nx can find the last release for each project on its own. Keep git.commit and git.tag enabled so the version bump, changelog edits and tags all point at the same commit.
CI has to see that history. Shallow clones and missing tags are the most common cause of wrong bumps. If Nx cannot find a project's previous tag, it cannot work out what changed since then, so it may treat every commit as new or fall back to a starting version. Fetch the full history and all tags before running any release command, for example with git fetch --unshallow --tags.
Split the pipeline into two jobs:
- Version job. On merge to main, run versioning and changelog generation without publishing, then push the commit and tags.
- Publish job. In a later job behind manual approval or an environment gate, check out the tagged commit and run
nx release publish.
Provide registry credentials through environment variables, for example a token referenced from .npmrc. Never commit tokens. Mark internal packages "private": true so the registry refuses them even if one is picked up by mistake.
Publishing is not atomic. Each package is a separate upload, so a network error can leave some packages published and others not. The recovery should be to run the publish job again, not to bump versions again. Before each upload, check whether that version already exists (npm view <name>@<version> version) and skip it if so.
If tags and commits are already pushed but publishing failed, do not delete or move those tags, because other clones may have fetched them. Fix the cause and publish the existing versions. If a published version is actually broken, release a new patch version rather than rewriting history.
Alternatives and impact on cost and engineering time
Independent versioning is one of four reasonable ways to ship several backend packages. The differences matter most when a release goes wrong.
| Approach | Setup effort | Ongoing maintenance | Consumer clarity | Typical failure mode |
|---|---|---|---|---|
| Independent Nx Release | Moderate: groups, commit conventions, CI | Low once the rules are enforced | High: each version reflects that package's changes | A mis-scoped commit bumps the wrong package |
| Fixed workspace-wide | Low | Low | Low: unchanged packages still get new versions | Consumers upgrade for no reason, or start ignoring bumps |
| Manual per-package | None | High, and it grows with each dependent | Depends on individual discipline | A dependent's version range is not updated |
| Separate repositories | High per repo | High: cross-package changes need coordinated PRs | High | Version skew between repos |
Engineering time is saved in three places. Dependent version ranges are updated from the project graph, so nobody has to trace them by hand. Changelogs are generated from commits. Releases are reproducible, and a dry run makes each one reviewable before anything is tagged or published.
Some costs are added. You have to enforce commit format with hooks or PR title checks. CI needs full git history and publish credentials. Someone has to read the dry-run output, and you need a written recovery procedure like the one above.
Illustrative estimate (assumed numbers, not a benchmark). Use this formula:
monthly hours saved = releases × (manual minutes − automated minutes) ÷ 60 − upkeep hours
Assume 8 releases a month, 45 minutes manual, 10 minutes automated and 3 hours of upkeep. That gives 8 × 35 ÷ 60 − 3 ≈ 1.7 hours. If you release less often, the setup may not pay for itself.
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 independent versioning, 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.