Make development environments boring with asdf and mise
The short version:
- If the project needs only one managed tool, keep a focused manager. Node with
nvmis the obvious example. - For a new project with several runtimes or CLIs, I would choose
mise. asdfis also a good solution, especially for a team already using it successfully.
The larger win is not the logo on the tool. It is putting intentional versions in the repository, giving the team one supported setup path, and exercising that path somewhere outside one developer’s laptop.
I came back to this topic while setting up a new MacBook. A fresh machine is a good way to discover which parts of a development environment are deliberate and which parts only exist because the old laptop slowly accumulated them.
Install Node. Install Ruby. Install Java. Use this version of CocoaPods. Do not update that CLI yet. After a few years, this becomes archaeology.
Put the versions in the project first
The painful part is not installation. It is uncertainty.
When something breaks, I want to know whether the project is broken or whether
I quietly ran it with a different toolchain than everyone else. asdf and
mise are polyglot version managers: they can select several runtimes and
tools from project-owned configuration. In this example, .tool-versions
turns part of that hidden machine state into project data:
nodejs 22.12.0
ruby 3.4.4
java temurin-21.0.6+7.0.LTS
With the manager and its required plugins or backends already configured, the setup command can become:
asdf install
or:
mise install
Those are repeatable project setup commands, not a complete fresh-machine bootstrap. The supported setup path should also record how to install the manager, any plugin or backend sources, and required system dependencies.
I reproduced the same .tool-versions flow with those Node, Ruby, and Java
versions in both mise and asdf. asdf requires the corresponding
plugins to be installed first. After
that, asdf install installs the
versions declared by the project.
The file alone is not a complete reproducible environment. It does not capture every system library, credential, IDE setting, or installer dependency. Automation can also repeat stale configuration perfectly. The useful sequence is simpler:
- Record the requirement in the repository.
- Pin or lock it where repeatability matters.
- Provide one visible setup command.
- Run the same path in CI or another clean environment.
CI should use the same declared versions, but it does not have to use the same installer. Cached setup actions or pinned images are fine when they are faster. Whenever possible, let CI read the repository’s version file instead of duplicating the values. Exercise the project’s setup command in at least one clean job so it remains reliable.
One tool or several?
For a project with one managed tool, a focused manager may be enough. Node is
the obvious example: nvm and an .nvmrc
handle its version without adding a polyglot layer. One tool, one small file,
not much drama.
Most projects I have worked on needed more than that. A mobile app can use Node for scripts, Ruby for Fastlane or CocoaPods, Java for Android tooling, and a few project-specific CLIs. Backend projects can reach the same point with Python, Go, Java, OpenTofu, or database tools.
At that point, separate managers and manual installations stop feeling simpler. They become personal setup choices stored in shell history instead of the repository.
This is also why I prefer one manager and setup path as the team default. Shared configuration makes onboarding repeatable, but shared troubleshooting is just as important. When a setup fails, teammates should recognize the command, plugin or backend, and configuration involved.
That does not mean a compatible alternative must be forbidden. A pinned
.tool-versions file can work as a bridge between ordinary asdf and mise use.
It does mean the team should have one path it supports instead of several
personal setups that happen to work today.
asdf or mise?
I used asdf for a few years. When I set up the new laptop, I tried mise as
an experiment instead of recreating the same setup. After a few months of
ordinary work, including project tooling upgrades, I am still happy with that
choice.
That does not mean the day-to-day difference is dramatic. I enter a project, get the expected versions, and mostly forget about the manager until the next upgrade. That is exactly the job.
The repository ages provide some context. asdf dates to 2014, while mise dates to 2023. That does not make asdf an unchanged old implementation. Version 0.16 was a complete rewrite from Bash to Go with breaking installation and command changes.
For a new multi-runtime project, I would choose mise. Its day-to-day interface
is comfortable, its
core tools cover Node, Ruby, and Java
without a separate plugin installation, and it can
read an ordinary pinned .tool-versions file.
That compatibility is a useful migration path, not a promise that mise is a
complete asdf replacement.
For a team already using asdf successfully, I would not migrate just to change the logo. Explicit versions and a shared setup path are the larger improvement.
Ruby and CocoaPods show both sides
Older iOS projects showed me both sides.
Parts of the CocoaPods toolchain did not support the Ruby version I was using.
Pinning a compatible Ruby resolved that part of the problem and narrowed later
debugging. But changing Ruby could also make a globally installed pod
executable disappear. The gem belonged to the previous Ruby environment.
The better project path is
using CocoaPods through Bundler:
declare CocoaPods in Gemfile, commit Gemfile.lock, run bundle install,
and invoke it with bundle exec pod. Bundler does not remove the Ruby
requirement. The selected Ruby still has to be compatible. Gemfile.lock pins
the Ruby tooling, while Podfile.lock separately pins the app’s pod
dependencies.
(If every required dependency can move to Swift Package Manager, Ruby may no longer be needed for dependency management. A project using Fastlane can still need Ruby.)
This is the tradeoff in a small form. A version manager solves hidden runtime drift, but it also exposes global dependencies that were attached to one runtime. For project commands, I prefer that exposure to become a manifest and a lockfile.
One manager does not mean every tool
Some development tools sit outside the simple runtime-manager flow.
Android Studio itself should run on its bundled JetBrains Runtime.
The JDK that runs Gradle is a separate choice. Gradle started inside the IDE
uses the JDK selected there, while Gradle started from a terminal uses
JAVA_HOME or java on PATH. I align those two Gradle paths on the same
compatible JDK. Installing another JDK with mise does not automatically change
the IDE’s choice. Gradle itself should already be project-owned through the
committed Gradle Wrapper.
Xcode is commonly installed from the Mac App Store, where an automatic update can replace the version a project expects. Large or long-lived projects may depend on a particular Xcode build, SDK, Swift version, simulator runtime, or supported architecture. I therefore keep the working version until an explicit project upgrade.
I use Xcode Releases as a practical catalogue of Xcode versions, build numbers, SDKs, Swift versions, and Apple download links. It is a third-party catalogue, not an Apple site. The expected Xcode version and build number still belong in the project’s documentation. Installing that version deliberately instead of relying on automatic App Store updates keeps it stable. It also avoids discovering, just when I want to start work, that Xcode has begun a long update.
sbt adds another ecosystem exception. For sbt projects, I sometimes keep Java
and the sbt launcher under SDKMAN, which the
sbt setup documentation
recommends considering. The project’s actual sbt version still belongs in
project/build.properties.
Both mise and
SDKMAN support a default Java version and a
project-specific override. This gives me room to use mise for most projects but
let an sbt project use .sdkmanrc when that is the clearer ecosystem path.
Within one project, I still choose one manager as the supported source of
JAVA_HOME.
I do not see these exceptions as failure. The goal is to reduce hidden setup decisions, not to force every tool through one manager.
There is also a trust cost. asdf community plugins sit outside the core project’s security policy, and mise configuration can require explicit trust. Replacing manual installation with a manager still means understanding what downloads and executes the tool. I would review plugin or backend source changes like dependency updates and avoid trusting executable configuration from untrusted branches.
Start where the friction is
You do not need to redesign the whole environment in one evening.
Start with the project that already needs several versions:
- Write down the versions it uses today.
- Choose a manager that covers the real tool list.
- Move project commands away from incidental global installations.
- Document the bootstrap, then make one project command install what is missing.
- Check that CI or another clean environment follows the same path.
Then verify the obvious things: node --version, ruby --version, and
java -version should report what the project declares. IDE-specific tools
should have their expected versions documented separately.
The result is not a perfectly reproducible universe. It is a smaller and more useful promise: when something breaks, fewer parts of the environment are unknown.
That is the kind of boring I want from a project that needs to live longer than one sprint.