← Back to Blog

Make development environments boring with asdf and mise

Published August 13, 20268 min read
Developer ToolingasdfmiseReproducibility

The short version:

  • If the project needs only one managed tool, keep a focused manager. Node with nvm is the obvious example.
  • For a new project with several runtimes or CLIs, I would choose mise.
  • asdf is 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:

  1. Record the requirement in the repository.
  2. Pin or lock it where repeatability matters.
  3. Provide one visible setup command.
  4. 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:

  1. Write down the versions it uses today.
  2. Choose a manager that covers the real tool list.
  3. Move project commands away from incidental global installations.
  4. Document the bootstrap, then make one project command install what is missing.
  5. 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.