Toolchain Version Management for Reproducible Onboarding
When two engineers run the same command and get different results, the cause is almost always an unpinned tool: one machine has Node 18, the other Node 20; one has Terraform 1.5, the other 1.7; the CI runner has whatever the base image shipped. Toolchain version management removes that variance by making the versions themselves a checked-in artifact, so a fresh git clone resolves the exact runtimes and CLIs the repository expects — no wiki page, no "install these first" list, no Slack thread. This guide is part of developer onboarding architecture and friction mapping; it sits directly upstream of runtime parity frameworks (which keeps the running stack honest) and README-driven automation (which wraps the pinned toolchain in a one-command bootstrap). Here we solve the layer beneath both: guaranteeing that the interpreter, compiler, and command-line binaries are byte-for-byte the same for everyone who touches the repository.
Prerequisites
Before pinning anything, confirm the host baseline. These tools install per-user and do not need root, but they do assume a working shell and a few system libraries.
- A POSIX shell:
bash4+ orzsh. The activation hooks below are written for both. git2.30+ (for.gitattributesand hook support used in the drift checks).- A C toolchain for source builds:
build-essentialon Debian/Ubuntu, the Xcode Command Line Tools on macOS (xcode-select --install), or thebase-develgroup on Arch. Precompiled runtimes skip this, but Python and Ruby frequently compile from source. curlandxzfor downloading and unpacking release archives.- For the Nix and devbox sections: the ability to enable the Nix daemon (a multi-user install writes to
/nix, which needs one-time sudo).
Pick exactly one manager per repository and commit its config. Mixing two managers that both claim PATH for node produces the drift this guide exists to delete. The four covered here — mise, asdf, Nix, and devbox — trade off differently on speed, hermeticity, and system-dependency coverage, and the decision tree later in the guide maps those trade-offs to concrete questions.
Section 1 - Make tool versions a checked-in contract
The core idea is small: a file at the repository root declares every runtime and CLI with an exact version, and a shim layer intercepts calls to those binaries and routes them to the declared version. Nothing on the host PATH outside the project changes; inside the project directory, node means this Node, terraform means this Terraform. The declaration is versioned alongside the code, so the toolchain moves in lockstep with the branch. Checking out a six-month-old commit gives you that commit's toolchain, not today's.
Two mechanisms make this work, and it is worth knowing which one your manager uses because they fail differently. Shims are tiny wrapper scripts placed early on PATH; calling node runs the shim, which reads the nearest version file and execs the real binary. Shims survive subshells and cron jobs because they do not depend on shell state, but they add a small exec on every call and can mask a tool the manager does not know about. A shell hook instead rewrites PATH on every directory change so the real binary sits first; it is faster per-call and shows the true binary in which, but it only applies to interactive shells that loaded the hook. mise uses a hybrid — a hook for interactive use plus optional shims for non-interactive contexts — while asdf is shim-first and Nix and devbox replace PATH wholesale inside their shells.
A second subtlety is pinning the manager itself. If the version file is authoritative but one engineer runs mise 2024.1 and another runs 2025.6, a resolution edge case can still differ. Record the manager version too: mise supports a [tools] self-entry and a .mise.toml min_version key, asdf is pinned by cloning a release tag as shown below, and Nix and devbox record their own version in the lockfile. Treat the manager as just another pinned tool. The reward for this discipline is that onboarding stops depending on host state entirely: it no longer matters whether a laptop is brand-new, inherited from a departed colleague, or three OS upgrades deep, because the toolchain is reconstructed from the repository rather than assumed from the machine. That property is what makes the setup step reviewable, cacheable, and safe to automate.
The remaining sections implement this contract with four managers. Whichever you pick, the shape is the same: a declaration file, an install step, an activation hook, and a drift check. Start with mise, which is the fastest to adopt and reads the other tools' config formats.
Section 2 - Pin runtimes and CLIs with mise
mise (formerly rtx) is a single Rust binary that manages runtimes, tasks, and environment variables. It is fast because it uses real shims plus a shell hook rather than re-resolving PATH on every prompt, and it reads a .mise.toml at the repository root. Install it per-user and add the activation hook to your shell profile.
#!/usr/bin/env bash
set -euo pipefail
# Install mise (per-user, no root)
curl -fsSL https://mise.run | sh
# Activate for the current and future shells
echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc
eval "$(~/.local/bin/mise activate bash)"
mise --version
Declare the toolchain in .mise.toml at the repository root. Use exact versions, never ranges — a range reintroduces the drift you are removing.
# .mise.toml — commit this file
[tools]
node = "20.11.1"
python = "3.12.2"
terraform = "1.7.4"
"npm:pnpm" = "9.1.0"
[env]
# Project-scoped env vars, applied only inside this directory tree
NODE_ENV = "development"
[settings]
# Fail loudly if a required tool is missing rather than falling back to host PATH
experimental = true
With the file committed, onboarding is two commands, and both are idempotent:
mise trust— mise refuses to auto-run config from untrusted directories; trusting the repo once records its path in~/.local/state/mise.mise install— resolves every entry in.mise.tomland installs any version not already present. Re-running it is a no-op once versions are present.
After that, cd into the project and the declared versions are active. Confirm the shims resolve as expected:
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
mise trust --quiet
mise install
# Drift check: mise doctor exits non-zero if any declared tool is missing,
# outdated, or shadowed by a host binary earlier on PATH.
mise doctor
mise current # prints the resolved version of every managed tool
mise resolves configuration from the most specific location to the most general, so a nested directory can override the root, and a local override file (git-ignored) can pin a personal version without touching the shared config. Understanding that precedence prevents surprises when a version "won't change."
mise also manages more than language runtimes. Its [tools] table accepts backends beyond the built-in plugins: npm: for Node CLIs, pipx: for Python tools, cargo: for Rust binaries, go: for Go modules, and aqua: or ubi: for arbitrary GitHub releases. That means a single .mise.toml can pin terraform, pnpm, ruff, and a project-specific linter in one place, so a contributor never has to install a CLI out of band. Pair that with the [tasks] table — mise can run and cache project commands the way make does — and the version file doubles as the entry point for common workflows, which is the same consolidation goal behind README-driven automation.
Because mise also reads .tool-versions and .node-version files, a team migrating off other managers can adopt it without rewriting existing pins on day one. That interoperability is why it pairs cleanly with the asdf format covered next.
Section 3 - The asdf .tool-versions contract
asdf is the older, plugin-based manager that popularized the .tool-versions file — a plain-text list of tool version pairs, one per line. It is ubiquitous in CI images and editor integrations, and its file format is a de facto standard that mise, direnv, and several IDEs also read. asdf itself relies on a plugin per language, which adds a step but makes the ecosystem large.
#!/usr/bin/env bash
set -euo pipefail
# Clone asdf (pin the release tag so the manager itself is reproducible)
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
. "$HOME/.asdf/asdf.sh"
# Add plugins for each runtime the repo needs
asdf plugin add nodejs
asdf plugin add python
asdf plugin add terraform
asdf --version
The declaration file is .tool-versions at the repository root. It is intentionally boring — no sections, no quoting, just names and exact versions:
nodejs 20.11.1
python 3.12.2
terraform 1.7.4
Onboarding is then asdf install, which reads .tool-versions, resolves each line against the matching plugin, and installs anything missing. Verify the pins and check for drift with asdf current, which prints the resolved version and the file that set it:
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
asdf install # installs every version listed in .tool-versions
# Drift check: fail if any tool's resolved version differs from the file.
while read -r tool version _; do
[ -z "$tool" ] && continue
resolved="$(asdf current "$tool" | awk '{print $2}')"
if [ "$resolved" != "$version" ]; then
echo "DRIFT: $tool resolved to $resolved, expected $version" >&2
exit 1
fi
done < .tool-versions
echo "toolchain matches .tool-versions"
The .tool-versions format and the .mise.toml format encode the same contract with different ergonomics. The comparison below shows why teams frequently keep the asdf file even after adopting mise: the flat file is trivially diff-able and machine-editable, while the TOML adds env vars and tasks in one place.
Two operational details keep an asdf setup honest. First, enable legacy_version_file = yes in ~/.asdfrc so asdf also respects single-tool files like .nvmrc and .ruby-version that other tooling writes; without it, an editor that updates .nvmrc can silently disagree with .tool-versions. Second, install plugins from pinned URLs or a vetted plugin index rather than trusting whatever the default registry resolves to, because a plugin is arbitrary shell that runs at install time — the same supply-chain caution you would apply to any build dependency. Record the plugin versions in your bootstrap script so the plugin layer is as reproducible as the runtimes it installs.
Neither asdf nor mise manages system libraries — the OpenSSL, libpq, or compiler that a runtime links against. When a build needs those to be reproducible too, reach for Nix.
Section 4 - Fully hermetic toolchains with Nix flakes
asdf and mise pin the runtime but inherit the host's system libraries, so a Python that links against the host libssl can still behave differently across machines. Nix closes that gap: a flake pins the entire dependency graph — compilers, shared libraries, and CLIs — to an exact revision of the package set, identified by a lockfile hash. The result is hermetic: the same flake.lock produces bit-identical closures on any machine with the Nix daemon.
Enable flakes and write a flake.nix with a development shell. The nixpkgs input is pinned by flake.lock, which you commit alongside the flake.
{
description = "Reproducible dev toolchain";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
outputs = { self, nixpkgs }:
let
forAll = f: nixpkgs.lib.genAttrs
[ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
(system: f nixpkgs.legacyPackages.${system});
in {
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = [
pkgs.nodejs_20
pkgs.python312
pkgs.terraform
];
shellHook = ''
echo "node $(node --version), python $(python --version)"
'';
};
});
};
}
Onboarding is a single command, and it is deterministic because the lockfile pins every transitive dependency:
#!/usr/bin/env bash
set -euo pipefail
# One-time: enable flakes for this user
mkdir -p ~/.config/nix
grep -q 'experimental-features' ~/.config/nix/nix.conf 2>/dev/null || \
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
cd "$(git rev-parse --show-toplevel)"
# Commit flake.lock so the closure is identical for everyone
nix flake lock
# Enter the hermetic shell; every binary comes from the pinned closure
nix develop --command bash -c 'node --version && terraform --version'
The drift check for Nix is structural rather than version-by-version: if flake.lock is unchanged, the closure hash is unchanged, so comparing the committed lock against a freshly evaluated one detects any accidental input bump.
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
before="$(sha256sum flake.lock | awk '{print $1}')"
nix flake update --commit-lock-file=false >/dev/null 2>&1 || true
after="$(sha256sum flake.lock | awk '{print $1}')"
if [ "$before" != "$after" ]; then
echo "DRIFT: flake.lock inputs moved; review before committing" >&2
git checkout -- flake.lock
exit 1
fi
echo "flake inputs pinned and unchanged"
The mechanism worth understanding is the closure: the transitive set of store paths a build depends on, each named by a hash of its inputs. Because nixpkgs is pinned by flake.lock and every derivation is content-addressed, two machines that evaluate the same flake produce the same store paths — the compiler, the shared libraries it links, and the runtime are all fixed. This is why a Nix dev shell removes the entire class of "works on my machine because my host libpq is newer" failures that shim-based managers leave open. The trade is disk and cold-start time: the first nix develop on a machine with an empty store downloads or builds the whole closure, though the binary cache at cache.nixos.org serves prebuilt paths for most common packages so the common case is a download, not a compile.
For day-to-day ergonomics, pair the flake with direnv and nix-direnv: a one-line .envrc containing use flake enters the pinned shell automatically on cd, and nix-direnv caches the evaluated environment so re-entry is instant instead of re-evaluating the flake. That closes the usability gap that historically pushed teams toward the lighter managers, while keeping the full hermeticity a lockfile-pinned closure provides.
Nix buys full hermeticity at the cost of a steeper learning curve and a larger /nix/store. When a team wants Nix's reproducibility without writing flake expressions, devbox provides a thin, JSON-driven layer on top.
Section 5 - devbox for a Nix-backed approachable shell
devbox wraps Nix with a devbox.json file and a devbox.lock lockfile, giving you Nix's hermetic package resolution through an npm-like interface — no flake syntax required. It resolves packages from the Nix package set, pins them in the lockfile, and drops you into an isolated shell where only the declared tools are on PATH.
#!/usr/bin/env bash
set -euo pipefail
# Install devbox (it manages its own embedded Nix if none is present)
curl -fsSL https://get.jetify.com/devbox | bash
devbox version
Declare packages by name and version. devbox resolves each to an exact Nix store path recorded in devbox.lock:
{
"packages": [
"[email protected]",
"[email protected]",
"[email protected]"
],
"shell": {
"init_hook": [
"echo Entering pinned devbox shell"
],
"scripts": {
"verify": [
"node --version",
"terraform --version"
]
}
}
}
Commit both devbox.json and devbox.lock. Onboarding and the drift check use devbox install and the lockfile hash, mirroring the Nix approach but without hand-written expressions:
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
devbox install # resolves packages against devbox.lock
# Drift check: the lock must fully describe the declared packages.
before="$(sha256sum devbox.lock | awk '{print $1}')"
devbox update --sync-lock >/dev/null 2>&1 || true
after="$(sha256sum devbox.lock | awk '{print $1}')"
if [ "$before" != "$after" ]; then
echo "DRIFT: devbox.lock changed; commit the update deliberately" >&2
git checkout -- devbox.lock
exit 1
fi
devbox run verify
Four managers cover a spectrum from "fast and simple" to "fully hermetic." Choosing between them is a small decision with two questions: do you need system-level libraries pinned, and do you want to write Nix?
Section 6 - Detect drift and enforce the pin in CI
Pinning is only durable if something rejects a mismatch. The enforcement point is CI: the pipeline installs from the same committed file and fails the build if a resolved version differs from the declaration, or if the lockfile is stale. That turns "someone bumped Node locally" from a silent Friday-afternoon mystery into a red check on the pull request. The CI parity validation reference covers the broader parity contract; here the job is narrow — assert the toolchain the runner uses is the toolchain the repo declares.
There are two failure modes to guard against, and they need different checks. The first is a version mismatch: the runner installs a tool at a version the declaration does not name, usually because a base image preinstalled it earlier on PATH and shadowed the managed shim. The doctor/current assertions catch this by comparing the resolved binary against the file. The second is a stale lockfile: for Nix and devbox, flake.lock or devbox.lock can lag behind the declaration if someone edited the human-readable file without regenerating the lock, so a fresh evaluation would resolve different store paths than a teammate got yesterday. The lockfile-hash comparison shown in the earlier sections catches that one. A complete CI gate runs both — the version assertion and the lockfile freshness check — because passing one while failing the other still ships drift.
Keep the toolchain check as its own fast job that runs before the expensive test matrix. It needs no application dependencies, finishes in seconds once the cache is warm, and failing early gives the author an unambiguous signal — "your Node is 20.12, the repo pins 20.11.1" — instead of a confusing test failure three stages later. When the same check also runs in a pre-commit hook, most drift never reaches CI at all.
The following Compose service runs the drift check in the exact image CI uses, so "passes locally" and "passes in CI" mean the same thing. It mounts the repository read-only and runs the manager's own verification.
# compose.ci.yaml — run: docker compose -f compose.ci.yaml run --rm toolchain-check
services:
toolchain-check:
image: jdxcode/mise:latest
working_dir: /repo
volumes:
- ./:/repo:ro
environment:
MISE_TRUSTED_CONFIG_PATHS: /repo
command:
- sh
- -euc
- |
mise install
mise doctor
# Assert every resolved tool matches .mise.toml exactly
mise current | while read -r tool version; do
echo "verified ${tool} ${version}"
done
Add a pre-commit hook so drift is caught before it reaches CI at all. A single mise doctor (or the asdf/Nix/devbox equivalent) run on commit keeps the local toolchain honest:
#!/usr/bin/env bash
set -euo pipefail
# .git/hooks/pre-commit — chmod +x this file
if command -v mise >/dev/null 2>&1; then
mise install --quiet
mise doctor || { echo "toolchain drift; run 'mise install' before committing" >&2; exit 1; }
fi
The payoff is measurable. When the toolchain resolves from a committed file, the setup step collapses from a manual checklist to one idempotent command, and the variance between engineers' first-run times drops toward zero. The chart below shows representative wall-clock setup times for a three-runtime project across the four approaches versus an unpinned manual install.
Platform caveats
Version managers behave differently across operating systems and CPU architectures, mostly around source builds and binary availability. Handle these before they surface as failed installs.
macOS (Apple Silicon, ARM64): Some runtimes still lack native
aarch64-darwinbinaries and compile from source, which needs the Xcode Command Line Tools. If a source build fails linking against Homebrew OpenSSL, exportLDFLAGSandCPPFLAGSpointing at theopenssl@3prefix, or prefer Nix/devbox where the library is part of the pinned closure and does not depend on Homebrew at all.
WSL2: Run the manager inside the Linux filesystem (
~/, not/mnt/c). Installing runtimes onto the Windows-mounted drive is slow and breaks executable permission bits, so shims fail to run. Keep the repository under the WSL2 home directory and let VS Code attach through the WSL remote.
Windows (native, no WSL): asdf and mise target POSIX shells; native PowerShell support is limited. For Windows-first teams, prefer devcontainers so the toolchain runs in a Linux container regardless of host — see devcontainer configuration standards for the container-side pin.
Nix on ARM64 Linux: the
aarch64-linuxbinary cache has good coverage but occasionally misses a package, triggering a local build. Add the project's own binary cache or accept the one-time compile; either way the resulting closure hash is identical to x86_64 for the same inputs.
Rollback and recovery
Every manager is additive and reversible — it installs into a per-user directory and hooks the shell, so removing it restores the host PATH.
- mise:
mise deactivatefor the current shell, then remove themise activateline from~/.bashrcand delete~/.local/share/miseto reclaim installed versions. - asdf: comment out the
asdf.shsource line andrm -rf ~/.asdf; the.tool-versionsfile is inert once asdf is gone. - Nix:
nix developshells are ephemeral, so exiting the shell fully reverts your environment;nix store gcreclaims disk once nothing references the closure. - devbox:
exitthe devbox shell;devbox rm <package>drops a single tool, and deleting.devbox/clears the project's resolved state without touching the committeddevbox.json.
To pin-rollback a version rather than the manager, revert the declaration file (git checkout <ref> -- .mise.toml) and re-run the install command; because the file is the source of truth, checking out an older commit restores that commit's toolchain exactly.
Frequently Asked Questions
Can I run mise and asdf in the same repository?
Not on the same tool. Both install shims that claim PATH for binaries like node, and whichever hook loads last wins — which is exactly the nondeterminism you are trying to remove. Pick one manager per repository. If you are migrating, mise reads asdf's .tool-versions natively, so you can adopt mise while keeping the existing file, then delete asdf once every contributor has switched.
Why pin exact versions instead of a range like node = "20"?
A range resolves to "the newest matching version installed on this machine," which differs across hosts and over time — reintroducing drift. An exact pin such as node = "20.11.1" resolves identically everywhere and only changes when someone edits the committed file, which shows up as a reviewable diff. Use ranges only for tools where patch-level differences genuinely cannot affect behavior, and even then prefer an exact pin plus a scheduled bump.
Do these managers pin system libraries like OpenSSL or a C compiler?
mise and asdf pin the runtime binary but inherit the host's system libraries, so a Python linked against a different host libssl can still behave differently. Nix and devbox pin the entire closure — compilers and shared libraries included — via a lockfile hash, giving true hermeticity. If reproducibility of native extensions matters, choose Nix or devbox; if you only need matching interpreter versions, mise or asdf is lighter.
How do I stop CI and local machines from drifting apart?
Run the identical install-and-verify step in both. In CI, install from the committed declaration file and fail the job if a resolved version differs or the lockfile is stale; locally, run the same check in a pre-commit hook. Executing the check inside the same container image CI uses removes the last variable, so "passes locally" and "passes in CI" describe the same toolchain.