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: bash 4+ or zsh. The activation hooks below are written for both.
  • git 2.30+ (for .gitattributes and hook support used in the drift checks).
  • A C toolchain for source builds: build-essential on Debian/Ubuntu, the Xcode Command Line Tools on macOS (xcode-select --install), or the base-devel group on Arch. Precompiled runtimes skip this, but Python and Ruby frequently compile from source.
  • curl and xz for 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.

From clone to identical toolchain A clone reads the pinned config, installs exact versions, and every developer ends with the same toolchain. Reproducible Onboarding Flow git clone reads pinned config resolve versions exact, not ranges install + shim PATH intercept identical toolchain Every engineer starts from the same resolved toolchain.
The pinned config is the single source of truth; the manager turns it into an installed, shimmed toolchain.

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:

  1. mise trust — mise refuses to auto-run config from untrusted directories; trusting the repo once records its path in ~/.local/state/mise.
  2. mise install — resolves every entry in .mise.toml and 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 config resolution order Four configuration sources from most specific to global; the first match wins. Version Resolution (first match wins) MISE_* env var override .mise.local.toml (git-ignored) .mise.toml (committed root) ~/.config/mise/config.toml
mise walks from a personal override down to global config; the committed root file is the shared contract.

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.

asdf file versus mise file Comparison of the flat tool-versions format against the richer mise TOML format. .tool-versions vs .mise.toml .tool-versions (asdf) flat name + version lines read by many tools no env vars or tasks plugin required per tool trivially diff-able .mise.toml (mise) TOML with sections tools + env + tasks no plugin install step reads .tool-versions too single static binary
Both encode exact pins; the TOML adds env and task management, the flat file wins on portability.

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?

Choosing a toolchain manager A decision from whether system libraries must be pinned to which manager fits. Which Manager? Must system libs be pinned too? No — runtimes only mise or asdf Yes — want to write Nix expressions? Yes: Nix flake full control No: devbox JSON over Nix
Runtimes-only needs point to mise or asdf; system-library hermeticity points to Nix or devbox.

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.

Setup time by approach Bar chart comparing first-run toolchain setup minutes for manual install versus four managers. First-Run Setup (minutes) manual 45 asdf 12 devbox 8 mise 6
Representative one-command setup times; the manual checklist also carries the highest variance between engineers.

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-darwin binaries and compile from source, which needs the Xcode Command Line Tools. If a source build fails linking against Homebrew OpenSSL, export LDFLAGS and CPPFLAGS pointing at the openssl@3 prefix, 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-linux binary 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 deactivate for the current shell, then remove the mise activate line from ~/.bashrc and delete ~/.local/share/mise to reclaim installed versions.
  • asdf: comment out the asdf.sh source line and rm -rf ~/.asdf; the .tool-versions file is inert once asdf is gone.
  • Nix: nix develop shells are ephemeral, so exiting the shell fully reverts your environment; nix store gc reclaims disk once nothing references the closure.
  • devbox: exit the devbox shell; devbox rm <package> drops a single tool, and deleting .devbox/ clears the project's resolved state without touching the committed devbox.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.