Skip to content

mise — the 80/20 course for polyglot projects

Scope: pin tool versions, load environment variables per directory, and run tasks on a developer machine and in CI. One tool replaces nvm + pyenv + rbenv + tfenv + asdf + direnv + just / make (partial) + dotenv loading. mise plugin authoring, the experimental UI, and deep vfox ecosystem work are intentionally out of scope — pointers at the end.

Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.


TL;DR — in 30 seconds:

  • One mise.toml pins every tool version, env var, and task for a polyglot repo, replacing the nvm / pyenv / rbenv / tfenv / direnv / just-or-make patchwork with one binary and one CI install line.
  • Activation (the default mode) rewrites PATH and env vars per directory on every cd, so [tools]/[env] apply automatically without a daemon or a shell replacement.
  • mise refuses to read a mise.toml's [tools] / [env] / [tasks] / [hooks] until you mise trust it — cloning a repo with mise config is, in spirit, like cloning one with a .envrc or a pre-commit hook.

1. Why mise — and when not to use it

The setup story on a polyglot repo without mise looks like this: install nvm for Node, pyenv for Python, rbenv for Ruby, tfenv for Terraform, direnv for env vars, just or make for tasks, plus a per-tool .nvmrc, .python-version, .tool-versions, .envrc. Six tools, four files, four shell hooks, and CI has to install all of them.

mise collapses this into one binary:

  • One file (mise.toml) lists every tool's version, every env var, every task.
  • One activation hook loads the right tools and env when you cd into the project.
  • One CLI installs, upgrades, runs, and inspects.
  • One install command in CI: mise install. Every tool, every version, deterministic.

What you get over the patchwork:

  • Versions pinned in code, reviewed in PRs. Bumping Node from 20.10 to 20.11 is a mise.toml edit; the next mise install picks it up across every machine.
  • direnv replacement. [env] in mise.toml is loaded on cd-in and unloaded on cd-out, with the same security model.
  • just replacement (partial). [tasks] and file-tasks under mise-tasks/ cover ~80% of just / npm-scripts / Makefile use cases.
  • asdf compatibility. .tool-versions is read transparently; asdf plugins run under mise's asdf: backend. Migrating from asdf is mostly a no-op.
  • CI is one line. mise install && mise run ci.

When not to reach for mise:

  • You ship a Docker image where the runtime is baked into the image → put mise inside the Dockerfile if you want, but the image fixes the version anyway.
  • You're on Windows without WSL → use the vfox backend only; the asdf backend is bash-only. Or use a Windows-native tool.
  • You need a true monorepo workspace manager (turborepo, nx) → mise doesn't replace those; it sits below them.
  • You need fully-reproducible builds across systems including system libs → reach for nix or devbox. mise is "pin tool versions"; nix is "pin everything down to glibc".

The 80/20 sweet spot mise nails: per-project tool versions + env vars + tasks for a polyglot repo, with a tiny CI footprint.


2. Mental model

flowchart TD
    A["Shell prompt<br/>cd ~/work/foo"] -->|every prompt| B["mise activate hook fires"]
    B -->|walk up from cwd| C["Config loading<br/>mise.toml · mise.local.toml<br/>.tool-versions · global config"]
    C --> D["Resolved config (merged)<br/>tools · env · tasks · vars"]
    D --> E["PATH<br/>prepended with tool shims/bins"]
    D --> F["env vars<br/>exported per [env]"]
    D --> G["runnable tasks<br/>by name"]
    D --> H["trust check"]

Three things to internalise:

  1. mise is a config loader + PATH manipulator. It doesn't replace your shell, it doesn't install a daemon. On every prompt, it computes "what should PATH and env look like in this directory?" and updates them.
  2. Activation vs shim mode. With activation (default and recommended), mise modifies PATH per directory; calling node finds the right binary directly. With shim mode, mise installs a shim called node on a stable PATH entry; the shim resolves the version on each call. Activation is faster; shim mode works when activation can't (cron jobs, non-interactive scripts, some editors).
  3. mise.toml carries four orthogonal concerns. Tools, env vars, tasks, vars. Same file, separate sections; pick what you need.

3. Setup

# macOS
brew install mise

# Linux / WSL
curl https://mise.run | sh

# Sanity
mise --version
mise doctor                                   # surfaces missing activation, plugin issues

Activate in your shell init file. Once, per machine:

# ~/.zshrc
eval "$(mise activate zsh)"

# ~/.bashrc
eval "$(mise activate bash)"

# fish
mise activate fish | source

Restart your shell (or source the init file). After this, every cd into a directory with mise.toml loads its tools and env automatically.

A first sanity check:

mkdir /tmp/mise-demo && cd /tmp/mise-demo
mise use node@20
node --version                                # → v20.x.x
cat mise.toml                                 # has [tools] node = "20"

mise wrote mise.toml, installed Node 20, and made node available on PATH — only in this directory. cd / and node --version falls back to whatever's outside mise (or errors out if nothing is installed).


4. The minimum viable mise.toml

[tools]
node = "20.11.0"
python = "3.12.2"
terraform = "1.7.0"
kubectl = "1.29.2"
helm = "3.14.0"
helmfile = "0.162.0"                          # pinned snapshot; helmfile 1.0+ is the current line
sops = "3.8.1"
age = "1.1.1"
yq = "4.40.5"
jq = "1.7.1"

Drop that in your repo root, mise trust, mise install. Every developer who clones the repo now gets the same versions of every tool.

Run it:

mise trust                                    # one-time per machine + per change
mise install                                  # install every tool listed
mise current                                  # show resolved versions
mise ls                                       # list installed versions

A second mise install is a no-op. Edit mise.toml, bump a version, mise install again — it installs only the new version and switches you to it.

That's the whole loop. Everything else (env, tasks, backends, environments) is composition on top of this one file.


5. Installing tools

Two ways to add a tool:

mise use writes config for you.

mise use node@20.11.0                         # adds [tools] node = "20.11.0" to nearest mise.toml
mise use python@3.12 yq@latest                # multiple at once
mise use -g rust@latest                       # global (~/.config/mise/config.toml)

Edit mise.toml by hand and mise install. When you're code-reviewing a config bump, this is what you do.

The most common operations:

mise install                                  # install everything in mise.toml that isn't installed
mise install node@20.11.0                     # install one version explicitly
mise install node                             # install whatever version mise.toml says for node

mise upgrade                                  # upgrade every tool to the latest matching its specifier
mise upgrade node                             # one tool

mise uninstall node@20.10.0                   # remove one installed version (doesn't edit mise.toml)
mise prune                                    # remove versions not referenced by any config

mise installs into ~/.local/share/mise/installs/<tool>/<version>/ by default. Disks fill up; mise prune is your friend.


6. Version specifiers — get this right or every machine drifts

The right-hand side of a [tools] entry. Seven forms:

Specifier Means Use when
"20.11.0" Exact version. Only this. Always, in shared configs.
"20" Latest patch of 20.x at install time. Personal tools; never in shared configs.
"20.11" Latest patch of 20.11.x at install time. Personal tools; never in shared configs.
"latest" Latest version at install time. Personal global tools (mise use -g jq@latest).
"lts" Latest LTS version (node, java). Personal use; surprising on shared configs.
"system" Skip mise's install; use the system's existing version. Tools that must come from system packages.
"ref:main" Build from a git ref (backends that support source builds). Tracking unreleased fixes; reinstalls every time.

The rule for any config you git add: always pin exactly. mise has no built-in lock file equivalent to package-lock.json; what you write in mise.toml is what every machine runs. Loose specifiers drift between machines and between CI runs.

For complex tools you can spell out the backend:

[tools]
node = "20.11.0"                              # uses the core backend
"npm:cowsay" = "latest"                       # npm-installed CLI
"cargo:ripgrep" = "14.1.0"                    # cargo-installed binary
"pipx:awscli" = "2.15.30"                     # python CLI in its own venv
"ubi:cli/cli" = "2.45.0"                      # GitHub-release binary; cli/cli = gh
"aqua:hashicorp/terraform" = "1.7.0"          # via the aqua registry
"go:github.com/x/y" = "1.2.0"                 # go install
"asdf:asdf-community/asdf-direnv" = "2.34.0"  # asdf plugin
"vfox:version-fox/vfox-nodejs" = "0.0.4"      # vfox plugin

For most tools, the short form (terraform = "1.7.0") works because mise's registry maps the short name to the right backend automatically.


7. Backends — what mise can install

mise resolves a tool name to a backend, which knows how to fetch + extract the binary. Nine built-in backends plus the registry that maps short names:

Backend What it installs Notes
core Hand-written installers for top tools (node, python, ruby, go, java, deno, bun, …) Fastest; first choice for anything in the core list.
asdf Any asdf plugin Bash-based, slower, unix-only.
vfox Any vfox plugin Lua-based, cross-platform; growing.
aqua Anything in the aqua catalog (~1500 CLIs from GitHub releases) Excellent for binary CLIs; no plugin needed.
ubi Binary from a GitHub-release URL pattern Last-resort for one-off binaries.
npm npm-published CLIs Per-project npm globals, basically.
cargo Rust crates via cargo install Replaces user-wide ~/.cargo/bin pollution.
pipx Python CLIs into isolated venvs Replaces hand-rolled pipx install.
go Go modules via go install For CLIs without GitHub releases.

You usually don't have to think about backends; the registry picks one for you. Spell it out only when you want a specific backend (e.g. forcing aqua because the core installer broke for your distro).

Discover what's available:

mise registry                                 # list registry entries (which backend each tool uses)
mise registry node                            # what backend for node?
mise ls-remote node                           # what versions of node exist?
mise ls-remote node 20                        # filter

8. Per-directory tools and env

The headline feature. Activation rewires PATH and env when the working directory changes.

/work/api/mise.toml:

[tools]
python = "3.12.2"

[env]
DATABASE_URL = "postgres://localhost/api_dev"
LOG_LEVEL = "debug"

/work/web/mise.toml:

[tools]
node = "20.11.0"

[env]
NEXT_PUBLIC_API_URL = "http://localhost:8000"

Behaviour:

cd /work/api
which python                                  # ~/.local/share/mise/installs/python/3.12.2/bin/python
echo $DATABASE_URL                            # postgres://localhost/api_dev
echo $NEXT_PUBLIC_API_URL                     #  (empty)

cd /work/web
which node                                    # ~/.local/share/mise/installs/node/20.11.0/bin/node
which python                                  # whatever's outside mise (or 'not found')
echo $NEXT_PUBLIC_API_URL                     # http://localhost:8000
echo $DATABASE_URL                            #  (empty)

[env] accepts several useful forms:

[env]
# Literal
DATABASE_URL = "postgres://localhost/api_dev"

# Computed at activation time via a template
AWS_ACCOUNT = "{{ exec(command='aws sts get-caller-identity --query Account --output text') }}"

# From another env var
CONFIG_PATH = "{{ env.HOME }}/.config/myapp"

# From a `[vars]` value
GREETING = "Hello, {{ vars.name }}"

# Special meta-keys
_.file = ".env"                               # load .env (dotenv format) from this dir
_.file = [".env", ".env.local"]               # multiple, later wins
_.path = ["./bin"]                            # prepend ./bin to PATH
_.python.venv = ".venv"                       # auto-activate a venv
_.source = "scripts/setup.sh"                 # source a shell script's env (advanced)
[vars]
name = "Thibault"

A few things to know:

  • _.file = ".env" is the direnv dotenv replacement. Path is relative to the mise.toml it's declared in.
  • Templates use Tera (Jinja-like, Rust-native). Functions: exec, env_dir, arch, os, plus access to env.X, vars.X, cwd.
  • Per-directory env layers. A child mise.toml's [env] overrides its parent's. Use this to set defaults at the repo root and overrides per subproject.

.tool-versions files are still read for asdf compatibility:

node 20.11.0
python 3.12.2

Treat it as legacy; for new repos use mise.toml.


9. Tasks — your just / npm-scripts / Makefile replacement

Two flavours: inline tasks (in mise.toml) and file tasks (in mise-tasks/ or .mise/tasks/).

Inline tasks are great for one-liners and small repos.

[tasks.lint]
description = "Run linters across the repo"
run = [
  "ruff check .",
  "prettier --check .",
]

[tasks.test]
description = "Run all tests"
depends = ["lint"]
run = "pytest -xvs"

[tasks.deploy]
description = "Apply the helmfile stack to the current cluster"
depends = ["test"]
env = { ENV = "prod" }
run = "helmfile -e $ENV apply --suppress-secrets"

[tasks."db:reset"]
description = "Reset the local dev DB"
run = [
  "dropdb api_dev || true",
  "createdb api_dev",
  "alembic upgrade head",
]

Run them:

mise tasks                                    # list every task with its description
mise run lint                                 # one task
mise run test                                 # runs `lint` first, then `test`
mise run db:reset                             # quoted names work too
mise run lint ::: test                        # run both, in parallel where possible

File tasks are scripts under mise-tasks/ (or .mise/tasks/). Better when the task is more than three lines, or when you want shell completion against arguments.

mise-tasks/release.sh:

#!/usr/bin/env bash
#MISE description="Cut a release and push the chart"
#MISE depends=["test"]
#USAGE arg "<version>" help="Semver to release"
set -euo pipefail

version="$1"
sed -i.bak "s/^version: .*/version: $version/" Chart.yaml
helm package . --version "$version"
helm push "demo-app-${version}.tgz" oci://ghcr.io/example/charts
chmod +x mise-tasks/release.sh
mise run release 0.2.0                        # mise picks up the script as a task
mise tasks                                    # release is listed with its description
mise run release --help                       # auto-generated help from USAGE comments

Three things tasks give you that ad-hoc scripts don't:

  1. Dependencies in a DAG. depends = ["lint", "test"] runs both before the task. mise dedupes — a dep that appears twice runs once.
  2. Env scoping. env = { FOO = "bar" } on a task adds env only for that run; the rest of the directory's env is layered underneath.
  3. Discovery. mise tasks lists everything; new contributors find what they can run without reading source.

10. Trust — why mise trust exists

Cloning a repo with mise.toml is, in spirit, equivalent to cloning a repo with a .envrc or .git/hooks/pre-commit: code in your filesystem can affect your shell. mise refuses to read [tools] / [env] / [tasks] / [hooks] until you've explicitly trusted the file.

cd /work/new-repo
mise current                                  # warns about untrusted mise.toml
mise trust                                    # trust the current dir's mise.toml
mise trust --untrust                          # revoke
mise trust --all                              # trust every mise.toml under cwd
mise trust ~/work/known-good-repo             # trust a path

For CI runners (no human to type mise trust), use the env var:

export MISE_TRUSTED_CONFIG_PATHS="$HOME/work,/builds"

This whitelists path prefixes; any mise.toml under those paths is trusted automatically. In a private CI runner this is fine; on a shared one, it's not.

After any change to a trusted mise.toml ([tools], [env], [tasks], [hooks]), mise re-asks for trust. That's intentional — a malicious PR that adds an [hooks] block runs nothing until you trust the change.


11. Migration map

You're using Replace with
asdf mise. Existing .tool-versions files work as-is. asdf plugins run under the asdf: backend; many tools also work via the faster core / aqua / ubi backends — switch as you go.
nvm mise use node@20.11.0. .nvmrc is not auto-read; convert to mise.toml or .tool-versions.
pyenv mise use python@3.12.2. .python-version is read by some tools, not by mise — convert to mise.toml.
rbenv mise use ruby@3.3.0. Same migration as pyenv.
tfenv mise use terraform@1.7.0. .terraform-version is not read — use mise.toml.
direnv Move .envrc exports into [env] in mise.toml. Use _.file = ".env" for dotenv-style file loading. direnv allow becomes mise trust.
just / make (project-level tasks) [tasks] in mise.toml covers ~80% (depends, env, parallel via :::). For deeper DAGs and partial-output caching, keep make.
npm scripts [tasks] in mise.toml. Each script becomes one task; mise tasks is your discovery surface.
dotenv (dotenv-cli) _.file = ".env" in [env] of mise.toml.

A common transition pattern: keep your existing .tool-versions for compatibility, add a mise.toml for env + tasks, switch to mise.toml [tools] last. Nothing breaks during the cut-over.


12. CI patterns

The standard CI install:

# GitHub Actions
- uses: jdx/mise-action@v2
  with:
    version: 2024.10.0          # pin mise itself
    install: true               # runs `mise install`
    cache: true                 # caches ~/.local/share/mise
- run: mise run ci

What that does, under the hood:

  1. Download the pinned mise binary.
  2. Restore the mise install cache (huge speedup — Node + Python + Terraform pre-extracted).
  3. mise install — install anything missing.
  4. mise run ci — run your ci task (defined in mise.toml).

If you don't want the action, the manual install is two lines:

- run: curl https://mise.run | sh
- run: |
    eval "$(mise activate bash)"
    mise install
    mise run ci

On other CIs (CircleCI, GitLab CI, Buildkite) the pattern is identical — install mise, restore cache, run.

A few CI-specific tips:

  • Trust the runner's config dir explicitly with MISE_TRUSTED_CONFIG_PATHS=$GITHUB_WORKSPACE (or equivalent).
  • Use mise exec for one-offs when activation is awkward: mise exec -- pytest -xvs.
  • Cache ~/.local/share/mise, not just dependencies. That's where tool installs live; rehydrating from cache is ~30x faster than re-installing.
  • Don't mise self-update in CI. Pin a version; bump it intentionally in a separate PR.

13. Worked example — polyglot repo

A repo with a Python API, a Node frontend, Terraform infra, and a Helmfile deploy. One mise.toml at the root with everything wired up.

Repo layout:

example-repo/
├── mise.toml
├── mise-tasks/
│   └── deploy.sh
├── api/
│   ├── pyproject.toml
│   └── src/
├── web/
│   ├── package.json
│   └── src/
├── infra/
│   ├── terraform.tf
│   └── main.tf
└── deploy/
    └── helmfile.yaml

Root mise.toml:

# Tool versions: every CLI this repo needs, pinned.
[tools]
python    = "3.12.2"
node      = "20.11.0"
terraform = "1.7.0"
kubectl   = "1.29.2"
helm      = "3.14.0"
helmfile  = "0.162.0"                          # pinned snapshot; helmfile 1.0+ is the current line
sops      = "3.8.1"
age       = "1.1.1"
yq        = "4.40.5"
jq        = "1.7.1"
"pipx:awscli"   = "2.15.30"
"npm:prettier"  = "3.2.5"

# Vars used by env and tasks templates.
[vars]
project = "example-repo"

[env]
# Repo-wide
PROJECT = "{{ vars.project }}"

# Auto-activate a Python venv at api/.venv if it exists.
_.python.venv = "api/.venv"

# Dotenv-style file for secrets that aren't in mise.toml (gitignored).
_.file = ".env"

# Per-tool defaults
AWS_REGION    = "eu-west-1"
KUBECONFIG    = "{{ env.HOME }}/.kube/config"

[tasks.bootstrap]
description = "Install Python deps + Node deps for first run"
run = [
  "cd api && python -m venv .venv && .venv/bin/pip install -e '.[dev]'",
  "cd web && npm install",
]

[tasks.lint]
description = "Lint everything"
run = [
  "cd api && ruff check src && ruff format --check src",
  "cd web && prettier --check .",
  "cd infra && terraform fmt -recursive -check",
]

[tasks.test]
description = "Run all tests"
depends = ["lint"]
run = [
  "cd api && pytest -xvs",
  "cd web && npm test",
]

[tasks."infra:plan"]
description = "Terraform plan against the current env"
run = "cd infra && terraform init -input=false && terraform plan -out plan.bin"

[tasks."infra:apply"]
description = "Apply the latest plan"
run = "cd infra && terraform apply plan.bin"

[tasks."deploy"]
description = "Helmfile apply for the given environment"
# File task — implementation lives in mise-tasks/deploy.sh
# (mise auto-discovers it; this entry is just a doc handle)

[tasks.ci]
description = "Pipeline entrypoint for CI"
run = [
  "mise run lint",
  "mise run test",
]

mise-tasks/deploy.sh:

#!/usr/bin/env bash
#MISE description="Helmfile apply for the given environment"
#MISE depends=["test"]
#USAGE arg "<env>" help="staging|prod"
set -euo pipefail

env="$1"
cd deploy
helmfile -e "$env" diff --suppress-secrets
helmfile -e "$env" apply --suppress-secrets

Onboarding a new contributor:

git clone git@github.com:example/example-repo.git
cd example-repo

# 1. Trust the config
mise trust

# 2. Install every tool listed
mise install

# 3. Bootstrap language deps
mise run bootstrap

# 4. Verify
mise current
which python                                  # repo's pinned 3.12.2
which terraform                               # repo's pinned 1.7.0

# 5. Run things
mise run lint
mise run test
mise run infra:plan
mise run deploy staging

CI workflow (GitHub Actions):

name: ci
on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    env:
      MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }}
    steps:
      - uses: actions/checkout@v4
      - uses: jdx/mise-action@v2
        with:
          version: 2024.10.0
          install: true
          cache: true
      - run: mise run ci

That's the whole loop. New tools land as mise.toml edits; CI picks them up next run; no per-tool installer logic anywhere.


14. What's intentionally out of scope here

Topic Pointer when you need it
Authoring mise plugins mise plugin SDK in the docs (asdf-style and vfox-style). Write one only when no backend covers your tool.
Deep vfox ecosystem The vfox project's repos and registry. Worth a separate study if you're on Windows + need many tools.
mise inside Docker RUN curl https://mise.run | sh && eval "$(mise activate bash)" && mise install in your Dockerfile. Works fine.
mise TUI / web UI mise ui (experimental); skim the docs if you want a visual config editor.
nix / devbox / proto / volta Alternative tools. nix gives you full reproducibility (system libs and all); devbox wraps nix for ergonomics; proto/volta are JS-ecosystem-focused. Choose at the org level.
mise hook integration with editors VS Code + JetBrains plugins exist; they read mise.toml to pick interpreters. Helpful in the editor; not load-bearing.
mise + shell completion details mise completion <shell> generates completions. Drop into your shell's completion path; one-time setup.
Mixing mise and asdf in the same shell Don't. Pick one — they fight over the same shims/PATH if both are activated.
MISE_* environment variables in depth Many knobs (MISE_DEFAULT_CONFIG_FILENAME, MISE_DATA_DIR, …); read mise settings and mise --help if you need them.

15. Reference card — daily commands

# Setup
brew install mise                                          # or: curl https://mise.run | sh
eval "$(mise activate zsh)"                                # add to ~/.zshrc
mise doctor

# Per-repo
mise trust                                                 # required after any [tools]/[env]/[tasks]/[hooks] change
mise install                                               # install everything in mise.toml
mise current                                               # show resolved tool versions
mise ls --current                                          # installed + active

# Editing config
mise use node@20.11.0                                      # add or update a tool
mise use -g rust@latest                                    # global config
mise use --rm node                                         # remove from mise.toml

# Tools
mise install node@20.11.0
mise upgrade
mise uninstall node@20.10.0
mise ls-remote node 20                                     # discover available versions
mise prune                                                 # delete unreferenced installs
mise registry node                                         # which backend mise uses

# Env
mise env                                                   # print resolved env in shell-eval form
eval "$(mise env)"                                         # apply env to current shell

# Tasks
mise tasks                                                 # list every task
mise run lint
mise run test
mise run lint ::: test                                     # parallel where possible
mise run release 0.2.0                                     # task with args

# Exec (no activation)
mise exec -- node script.js
mise exec node@20.11.0 -- node --version

# Trust
mise trust                                                 # current dir
mise trust --untrust
mise trust --all                                           # everything under cwd

# Diagnosis
mise doctor
mise --debug install                                       # verbose
mise settings                                              # all settings + values

That's the 80%. Go put a mise.toml at the root of your next polyglot repo and never type nvm use again.


16. How this connects

  • This course's own submodules — Config hierarchy & precedence, Tasks, and Environments & backends go deeper on §2/§8's merge order, §9's task DAG, and §1/§7's environment-vs-backend split respectively. Read them next if a section here felt compressed.
  • Terraform's CI page (module 07, Terraform, "plan in CI" §2) — this page's §12 mise install && mise run ci is the install step a pipeline runs before that page's plan/apply symmetry ever executes; mise pins the tool versions, Terraform's CI page pins what happens once those tools run.
  • Ansible (module 08, Ansible — its README's core guarantee) — §4's "a second mise install is a no-op" is the same idempotency property Ansible names as its central guarantee: re-running either tool against an already-converged state changes nothing.