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 deepvfoxecosystem 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.tomlpins every tool version, env var, and task for a polyglot repo, replacing thenvm/pyenv/rbenv/tfenv/direnv/just-or-makepatchwork with one binary and one CI install line. - Activation (the default mode) rewrites
PATHand env vars per directory on everycd, 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 youmise trustit — cloning a repo with mise config is, in spirit, like cloning one with a.envrcor 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
cdinto 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.tomledit; the nextmise installpicks it up across every machine. direnvreplacement.[env]inmise.tomlis loaded on cd-in and unloaded on cd-out, with the same security model.justreplacement (partial).[tasks]and file-tasks undermise-tasks/cover ~80% ofjust/ npm-scripts / Makefile use cases.- asdf compatibility.
.tool-versionsis read transparently; asdf plugins run under mise'sasdf: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
vfoxbackend only; theasdfbackend 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
nixordevbox. 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:
- 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
PATHandenvlook like in this directory?" and updates them. - Activation vs shim mode. With activation (default and recommended), mise modifies
PATHper directory; callingnodefinds the right binary directly. With shim mode, mise installs a shim callednodeon a stablePATHentry; 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). mise.tomlcarries 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 thedirenv dotenvreplacement. Path is relative to themise.tomlit's declared in.- Templates use Tera (Jinja-like, Rust-native). Functions:
exec,env_dir,arch,os, plus access toenv.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:
- Dependencies in a DAG.
depends = ["lint", "test"]runs both before the task. mise dedupes — a dep that appears twice runs once. - Env scoping.
env = { FOO = "bar" }on a task adds env only for that run; the rest of the directory's env is layered underneath. - Discovery.
mise taskslists 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:
- Download the pinned mise binary.
- Restore the mise install cache (huge speedup — Node + Python + Terraform pre-extracted).
mise install— install anything missing.mise run ci— run yourcitask (defined inmise.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 execfor 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-updatein 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, "
planin CI" §2) — this page's §12mise install && mise run ciis the install step a pipeline runs before that page'splan/applysymmetry 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 installis 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.