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.toml like a script. [env], templates and some tasks can run code, which is why mise has mise trust. Its docs also note that mise install, mise exec and mise run trust 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 outdated regularly, or let Renovate open the pull requests.

Takeaways

  • One mise.toml per 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 lock adds checksums; mise install --locked in CI makes the pipeline install exactly what you tested.
  • mise.local.toml is 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.

References