A few years ago I wrote DVM, a small script that switched my machine between decK versions during a Kong upgrade. It worked, but it solved one tool, and it solved it globally: switch decK for one project and every other terminal got the same version.
The real problem was never just decK. On a normal week I touch one project on one Kubernetes version, a lab on another, a Helm 3 chart and a Helm 4 one, Terraform, jq and yq in scripts, and this site on a specific Hugo. Each repo quietly assumes a version of each CLI. When that assumption is wrong you don’t get a clear error, you get a flag that doesn’t exist, a slightly different output, or a CI run that disagrees with your laptop.
These days one file in each repo handles all of it: a mise.toml. It’s what I recommend instead of DVM, and instead of a separate version manager per tool. This post shows the setup I use, what it fixed, and the one gotcha I hit.
Versions used: mise 2026.9.9 on macOS (Apple Silicon) · decK 1.38.0 and 1.62.1 · kongctl 1.18.0 and 1.19.0
Why per-project versions matter
Three examples from my own repos:
- decK changes its flags. The same conversion needs different arguments depending on the decK version. More on that below.
- kubectl has a support window. The Kubernetes version skew policy says kubectl is supported within one minor version of the API server. A laptop-wide kubectl that’s right for one cluster is out of policy for another.
- New major versions arrive on their own schedule. Helm 4 is out; plenty of charts and pipelines still expect Helm 3. A global upgrade breaks the repo you didn’t think about.
A version manager per tool (nvm, pyenv, tfenv, DVM) fixes one of these each. mise fixes them with one file and one command, for language runtimes and plain CLIs alike.
How mise works
mise reads a mise.toml in the current directory and its parents, merges it with your global config (~/.config/mise/config.toml), and puts the requested versions on your PATH. Change directory and the versions change with you.
Each version is installed once under ~/.local/share/mise/installs/<tool>/<version>/ and shared between projects. Tools come from a registry that maps a short name to a backend. Most CLIs use aqua, which downloads the official release asset from the project’s own releases. Anything published as a GitHub release works through the github: backend, even when it isn’t in the registry.
The setup
Install mise and hook it into your shell following the getting started guide. Then, in a repo:
mise use installs the tools and writes them to mise.toml. After a few of those, a Kong platform repo of mine looks like this:
[tools]
deck = "1.62.1"
"github:Kong/kongctl" = "1.19.0"
kubectl = "1.36.1"
helm = "4.2.0"
kind = "0.33.0"
jq = "1.8.1"
yq = "4.48.1"
kongctl isn’t in the mise registry yet, so it uses github:Kong/kongctl. Same idea for any CLI that ships GitHub release binaries.
A teammate who clones the repo runs one command:
mise install
And can see what’s active and where each version comes from:
mise ls --current --local
deck 1.62.1 ~/src/kong-platform/mise.toml 1.62.1
github:Kong/kongctl 1.19.0 ~/src/kong-platform/mise.toml 1.19.0
helm 4.2.0 ~/src/kong-platform/mise.toml 4.2.0
jq 1.8.1 ~/src/kong-platform/mise.toml 1.8.1
kind 0.33.0 ~/src/kong-platform/mise.toml 0.33.0
kubectl 1.36.1 ~/src/kong-platform/mise.toml 1.36.1
yq 4.48.1 ~/src/kong-platform/mise.toml 4.48.1
The project pin wins over the global one. My global config has kongctl 1.18.0; inside the repo I get 1.19.0, one directory up I get 1.18.0 again:
$ kongctl version # inside the repo
1.19.0
$ cd .. && kongctl version # outside
1.18.0
Nothing here is Kong-specific. The same file can pin node, python, go, terraform, gh, hugo-extended or anything else in the registry.
Run another version without changing anything
This is the job DVM was built for: run the same file through two decK versions and compare. With mise it’s a one-off, no switching:
mise exec [email protected] -- deck version
decK v1.38.0 (b6aba61)
The version is installed on first use and nothing in mise.toml changes. Here’s why it matters. I converted a Kong 2.x file to the 3.x format with the project’s decK 1.62.1:
deck file convert --from 2.8 --to 3.4 \
--input-file kong-2x.yaml --output-file kong.yaml
The same command on decK 1.38.0:
Error: invalid format: '2.8'
The older version only knows the long names. Its help shows the difference:
mise exec [email protected] -- deck file convert --help | grep -- '--from '
mise exec [email protected] -- deck file convert --help | grep -- '--from '
--from string format of the source file, allowed formats: [kong-gateway kong-gateway-2.x]
--from string format of the source file, allowed formats: [kong-gateway kong-gateway-2.x 2.8 3.4 3.10]
With --from kong-gateway-2.x --to kong-gateway-3.x, decK 1.38.0 produced a file identical to the 1.62.1 output. A runbook written against one decK fails on another for a reason that has nothing to do with your config. Pinning the version in the repo, next to the runbook, removes that whole class of problem.
Personal overrides
Sometimes you want a different version without changing the team’s file. Put it in mise.local.toml, which mise reads next to mise.toml, and add it to .gitignore:
# mise.local.toml - not committed
[tools]
deck = "1.38.0"
$ deck version
decK v1.38.0 (b6aba61)
Delete the file and you’re back on the project version.
Environment variables and tasks
mise.toml can also carry the environment and the tasks a repo needs, so the tools and the commands that use them live together:
[env]
DECK_KONNECT_ADDR = "https://eu.api.konghq.com"
[tasks.convert]
description = "Convert a 2.8 config to 3.4"
run = "deck file convert --from 2.8 --to 3.4 --input-file kong-2x.yaml --output-file kong.yaml"
[tasks.validate]
description = "Validate the Kong config"
run = "deck file validate kong.yaml"
mise run convert && mise run validate
Each task runs with the pinned tools on PATH, so mise run validate uses decK 1.62.1 even on a machine whose global decK is something else. Keep secrets out of [env]; reference a file you don’t commit instead.
If you already use a task runner such as Make or Task, keep it. mise doesn’t need to own your tasks to be worth it for the tool pins alone.
Lock it: exact versions and checksums
A version string says what you want. A lockfile records what you got, and where from:
mise lock
That writes mise.lock with the download URL and SHA-256 checksum of every tool, for each platform your team uses:
[[tools.deck]]
version = "1.62.1"
backend = "aqua:Kong/deck"
[tools.deck."platforms.linux-x64"]
checksum = "sha256:1846f7a0638d2bb1668e30e7faee61e3981d25a3073b2e577b5e0f848cf9e607"
url = "https://github.com/Kong/deck/releases/download/v1.62.1/deck_1.62.1_linux_amd64.tar.gz"
Commit it with mise.toml. Teammates and CI then run mise install --locked, which installs exactly what the lockfile records or fails (lockfile docs). To upgrade on purpose: mise lock --bump deck, review the diff, commit.
The gotcha: on my laptop, mise install --locked failed with lines like:
mise ✗ [email protected] failed: [email protected] is not in the lockfile
Those were my global tools, which a project lockfile doesn’t cover. CI has no global config, so there it passes. Locally, either lock your globals too (mise lock --global) or run the locked install only where it matters: in CI.
The same pins in CI
The mise GitHub Action installs mise and runs mise install from your repo’s config, so the pipeline uses the versions you use:
name: kong-config
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jdx/mise-action@v5
with:
install_args: "--locked"
- run: mise run validate
No separate “set up decK” or “set up kubectl” step, and no second place where a version is written down. When you bump mise.toml in a pull request, the pipeline tests the new version in that same pull request. Renovate has a mise manager if you want those bumps proposed for you.
For production pipelines, pin the action to a commit SHA rather than a tag, as you would any third-party action.
Before you adopt it
- Treat a repo’s
mise.tomllike a script.[env], templates and some tasks can run code, which is why mise hasmise trust. Its docs also note thatmise install,mise execandmise runtrust the active config automatically. Read the file in a repo you don’t know before running those. - It pins CLIs, not systems. mise doesn’t give you a reproducible OS, system libraries or services. If the problem is “my laptop isn’t the build machine”, a dev container or a container image is the better tool. They combine well: mise inside the container still pins the CLIs.
- Not every tool is in the registry.
github:covers most single-binary CLIs. For anything else, check the registry before you commit to it for a team. - Upgrades are now your job. Pinned means nothing moves until someone bumps it. Check
mise outdatedregularly, or let Renovate open the pull requests.
Takeaways
- One
mise.tomlper repo pins every CLI the repo needs: Kong tools, Kubernetes tools, language runtimes. mise exec tool@version -- ...answers “does this work on the other version?” without switching anything.mise lockadds checksums;mise install --lockedin CI makes the pipeline install exactly what you tested.mise.local.tomlis for personal overrides; keep it out of git.- Use mise instead of DVM, and instead of one version manager per tool (nvm, pyenv, tfenv). It’s what I recommend for any CLI.