Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·12 min read

Self-Contained mise in Dagger: Eliminating Tool Version Drift

Committing a self-contained mise bootstrap, mounting persistent cache volumes in Dagger, and enforcing the lockfile everywhere so mise.toml is the only source of truth for the toolchain.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

I had mise.toml pinning pnpm 11.7.0, CI running mise install pnpm@11.7.0, and Dagger running npm install -g pnpm@11.7.0. Three sources of truth for one version. When I bumped mise.toml to 11.8.0, the CI @version suffix was still 11.7.0 and CI failed with “no lockfile URL found.” Dagger still installed 11.7.0. Renovate opened a PR for mise.toml but CI and Dagger ignored it.

Note

Tested with Dagger 0.21.7 and pnpm 11.8.0 on 2026-07-16. Cache-volume reuse described here is scoped to repeated calls against the same Dagger Engine. A fresh engine on an ephemeral CI runner starts cold.

Sources of drift

Hardcoded @version suffixes in CI and Dagger commands create a second source of truth. The version in mise.toml and the version in the CI command must match, and nothing checks that they do until the CI fails.

I first tried Corepack: corepack enable pnpm reads package.json#packageManager, a single source of truth. But Corepack stops shipping with Node.js 25. It stays available via npm install -g corepack, only unbundled from the runtime. I wanted to avoid depending on a tool whose distribution model was changing, so I looked for a source of truth that would survive the next Node bump.

Single-source design

CI: drop @version

mise install with no arguments reads tool versions from mise.toml. The long-form @version syntax bypasses mise.toml entirely.

# .github/workflows/ci.yml - before (two sources of truth):
MISE_LOCKED=1 mise install node@26.1.0 pnpm@11.7.0

# After (single source of truth):
MISE_LOCKED=1 mise install node pnpm

I bump mise.toml and CI picks up the new version automatically. MISE_LOCKED=1 still verifies mise.lock checksums. The Renovate mise manager creates a single PR that updates mise.toml. CI has no version string to drift.

Dagger: bootstrap script + cache

Dagger containers are ephemeral Linux environments. Node images ship npm but not mise. The mise CI docs recommend two approaches for any CI provider: curl https://mise.run | sh (primary) or mise generate bootstrap -l -w (alternative with a committed bin/mise script). I went with the bootstrap approach: the script is committed to the repo, and the Dagger function copies it into the container along with the rest of the source. The script downloads the pinned mise binary on first run, then reuses the cached binary on subsequent runs.

// ci/src/index.ts - nodeBase()
private nodeBase(source: Directory) {
  const miseBootstrap = "/app/bin/mise";
  const miseCache = dag.cacheVolume("mise-data");
  const pnpmCache = dag.cacheVolume("pnpm-store");
  return dag
    .container()
    .from("node:26.1.0-slim")
    .withExec(["apt-get", "update", "-qq"])
    .withExec(["apt-get", "install", "-y", "-qq", "curl", "ca-certificates"])
    .withExec(["mkdir", "-p", "/app"])
    .withWorkdir("/app")
    // Copy the committed bootstrap script before the cache mount, so the
    // script is in place when the cache restores .mise/ across runs
    .withFile("/app/bin/mise", source.file("bin/mise"))
    // Self-contained mise inside the project tree (mise CI convention).
    // The bootstrap script sets MISE_DATA_DIR=/app/.mise, a versioned
    // MISE_INSTALL_PATH inside it, and the related state dirs. It then
    // runs the mise binary directly. Those env vars only apply to the
    // bootstrap's own subprocess, so we re-set the same set in Dagger to
    // keep every subsequent withExec call resolving from the same state
    // (see the "mise env vars set in Dagger too" bullet below).
    .withEnvVariable("MISE_DATA_DIR", "/app/.mise")
    .withEnvVariable("MISE_CONFIG_DIR", "/app/.mise/config")
    .withEnvVariable("MISE_CACHE_DIR", "/app/.mise/cache")
    .withEnvVariable("MISE_STATE_DIR", "/app/.mise/state")
    .withEnvVariable("MISE_YES", "1")
    .withEnvVariable("MISE_AUTO_INSTALL", "false")
    .withEnvVariable("MISE_TRUSTED_CONFIG_PATHS", "/app")
    // Copy mise config BEFORE the bootstrap, so `mise install` reads
    // versions from mise.toml rather than falling back to the latest
    // (see the ordering-of-withFile-and-withExec lesson in
    // "What I'd Do Differently")
    .withFile("/app/mise.toml", source.file("mise.toml"))
    .withFile("/app/mise.lock", source.file("mise.lock"))
    // MISE_LOCKED=1 must be set before the install, not after, so the
    // install itself verifies checksums against the lockfile
    .withEnvVariable("MISE_LOCKED", "1")
    // Mount cache volumes before running the bootstrap, so tool installs
    // and the versioned mise binary survive calls handled by this engine
    .withMountedCache("/app/.mise", miseCache)
    .withMountedCache("/pnpm-store", pnpmCache)
    // Bootstrap installs mise (cached on subsequent runs) and runs `mise install`
    .withExec([miseBootstrap, "install", "pnpm"])
    // mise shims on PATH - pnpm commands work natively
    .withEnvVariable(
      "PATH",
      "/app/.mise/shims:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin",
    )
    .withFile("/app/package.json", source.file("package.json"))
    .withFile("/app/pnpm-lock.yaml", source.file("pnpm-lock.yaml"))
    .withFile("/app/pnpm-workspace.yaml", source.file("pnpm-workspace.yaml"))
    // Pin pnpm store to the cached volume, then install with --prefer-offline
    .withExec(["pnpm", "config", "set", "store-dir", "/pnpm-store"])
    .withExec(["pnpm", "install", "--frozen-lockfile", "--prefer-offline"])
    .withDirectory("/app", source, { exclude: [".git", "node_modules", ".mise"] });
}

Ten lines of mise setup, two cache mounts, and one bootstrap exec; the rest is the project source. The pnpm version comes from mise.toml, the mise binary version comes from the committed bin/mise script, and the lockfile pins both.

Here’s why I chose each setting:

  • /app/.mise/ not /tmp/.mise/. The two locations are equivalent in mise’s eyes; both work with MISE_DATA_DIR. I picked /app/.mise/ because it is the mise official convention for CI and because the bootstrap script (bin/mise) is designed to point there. The trade-off is that the project tree now contains a .mise/ directory inside the Dagger container, which means I have to add .mise/ to three ignore lists: .gitignore, the ESLint ignores array, and the Dagger withDirectory exclude. The first two keep the directory out of version control and out of the linter’s tree walk. The third keeps the cache out of any exported container snapshot. The reward for this discipline is a cleaner Dagger function: mise auto-discovers mise.toml in the parent of MISE_DATA_DIR, so no manual workdir juggling is needed, and the same /app/.mise/ path works identically across local development, CI, and Dagger.
  • bin/mise not curl | sh. The bootstrap approach commits a 14 KB shell script to the repo. The script is generated by mise generate bootstrap -l -w -V 2026.6.11 and contains the mise binary version as a literal string inside. On first run, the script downloads the pinned mise binary to .mise/mise-2026.6.11 and execs it. On subsequent runs, the binary is already in the cache and the script is a near-no-op. The trade-off is the committed file: 14 KB is small, and the file is generated (not hand-written), so updates are mechanical (mise generate bootstrap -V <new>). The reward is full reproducibility: the mise binary version is in the repo, not in a curl URL, and the project does not depend on mise.run being reachable at build time.
  • MISE_DATA_DIR (and the related state dirs) set in Dagger too. The bootstrap script sets MISE_DATA_DIR=/app/.mise, MISE_CONFIG_DIR=/app/.mise/config, MISE_CACHE_DIR=/app/.mise/cache, MISE_STATE_DIR=/app/.mise/state, and a versioned MISE_INSTALL_PATH inside it, but those env vars only apply to the bootstrap’s own subprocess. They do not persist to subsequent Dagger withExec calls. The pnpm shim is a symlink to the mise binary; when called as pnpm, the binary dispatches to the pnpm backend and looks up tools in MISE_DATA_DIR. Without re-setting the env vars in Dagger, every shim call would default to ~/.local/share/mise and find nothing. MISE_YES=1 keeps mise non-interactive in case it ever decides to prompt for confirmation. The Dagger function re-sets all five env vars so the cached mise state and the shim calls resolve consistently.
  • MISE_LOCKED=1 is set in Dagger too. The lockfile is enforced in both CI and the local Dagger run. The lockfile already includes linux-arm64 entries (added when the lockfile was created on 2026-06-04), so the developer-machine Dagger run resolves checksums for the host platform. Adding MISE_LOCKED=1 to the Dagger container is the most reliable setup: a checksum mismatch fails the local Dagger run before it can fail the CI run, and the failure message is the same in both places. The Dagger run is part of the same supply chain as CI, not a separate workflow that can be allowed to drift.
  • withMountedCache for both /app/.mise and /pnpm-store. Dagger has two caching mechanisms: layer caching (automatic, in-sequence) and cache volumes (explicit, engine-scoped storage). Layer caching alone would re-run mise install pnpm and pnpm install --frozen-lockfile when the relevant layer is cold. Cache volumes retain the mise data directory and pnpm store for later calls handled by the same engine. I observed a cold-to-warm speedup from 45 seconds to 5 seconds locally, a 9× improvement on one persistent engine. Ephemeral GitHub-hosted jobs create a fresh local engine, so this measurement does not describe cross-job CI reuse. That requires a persistent or shared engine, Dagger Cloud’s distributed cache, or an explicit bridge to external storage.

Renovate: auto-update mise.toml

With tool versions no longer hardcoded anywhere, I enabled Renovate’s mise manager to open a grouped PR when mise.toml changes. The manager is the standard way to keep mise.toml in sync; it reads the tools table, queries the appropriate datasource for each tool (npm for node, pnpm for pnpm, github-releases for dagger, and so on), and proposes version bumps in a single PR.

// renovate.json
{
  matchManagers: ["mise"],
  groupName: "mise toolchain",
}

CI and Dagger pick up the bump automatically. The only touchpoint is merging the Renovate PR. One caveat worth knowing: the manager only updates the primary version listed for each tool, not fallback versions. If a tool entry looks like node = ["26.1.0", "25.0.0"], Renovate touches 26.1.0 and leaves 25.0.0 alone. For this project every tool has a single version, so the caveat is theoretical, but it is good to know if a project ever needs fallback versions for compatibility testing.

The bootstrap script’s mise binary version is not covered by the Renovate mise manager. It is updated manually by re-running mise generate bootstrap -V <new-version>. The generated script is committed, so Renovate would detect the diff and open a PR, but the tool version bump is a deliberate operation, not an automated one.

Verified outcome

Zero hardcoded tool versions remain across CI, Dagger, and local development. Mise supplies the tool versions, the bootstrap script pins the mise binary, and the lockfile pins tool checksums.

On frangonf.com, a cold local Dagger check against a fresh engine takes about 45 seconds. A later call against the same warm engine takes about 5 seconds because it reuses the mise and pnpm cache volumes. I observed similar timings in a private sibling project. Fresh engines on ephemeral CI runners do not inherit these volumes.

Note

The same single-source-of-truth principle now covers the Corepack packageManager field in package.json. The field is removed in favor of mise, and pmOnFail: error in pnpm-workspace.yaml is removed with it. engineStrict (range guard) carries the workload the duplicate was meant to carry. The failure that motivated the change and the cross-manager drift pattern are in this TIL.

Lessons from the migration

I’d drop @version from CI the same day I add mise.toml. The hardcoded suffix served no purpose. Mise already knows which version to install. I left it there out of habit, and it created the drift surface I later had to fix.

I’d enable MISE_LOCKED=1 in the Dagger container from the start, not as a follow-up. The lockfile already includes linux-arm64 entries from the day it was created, so checksum enforcement works on Apple Silicon hosts without any extra setup. Skipping MISE_LOCKED=1 in the Dagger run meant a checksum mismatch would only surface in CI, not in the local Dagger run that mirrors the CI workflow. Adding it later cost one extra round of pnpm install failures in CI; adding it from the start would have surfaced the same failures locally, with the same error message, on the developer’s machine.

I’d add the cache volumes and bootstrap script from day one, not as a follow-up. The biggest single performance win in the whole Dagger function is withMountedCache("/app/.mise", miseCache). Without it, every Dagger run re-downloads the mise binary (~30 MB) and re-installs pnpm (~150 MB) from scratch. With it, the warm-cache run is a 9× speedup. The bootstrap script is a smaller win, but it removes the network call entirely and pins the mise binary version in the repo. Both belong in the first version of the Dagger function, not as later additions.

I’d copy mise.toml and mise.lock before the bootstrap, not after. This is a bug I shipped. The Dagger function originally had the bootstrap’s withExec([miseBootstrap, "install", "pnpm"]) running before the withFile("/app/mise.toml") copy. When the bootstrap executed mise install pnpm, mise.toml was not yet on disk, so mise fell back to the latest pnpm and installed that. The shim then dispatched on the version in mise.toml (now present) and failed with “Tool not installed for shim.” The fix is one block move: copy mise.toml and mise.lock before the bootstrap, and set MISE_LOCKED=1 in the same place so the install verifies against the lockfile rather than running an unverified install that the lockfile check then fails on. The lesson generalizes: anything that affects tool resolution (config files, lockfiles, lockfile-enforcement env vars) must be in place before the tool-resolution call. I would not have caught this without the CI failure, because the local Dagger cache had pnpm 11.7.0 already from an earlier code version, so the bootstrap’s “no install needed” path ran instead of the broken “install latest” path. A warm Dagger cache can mask bootstrap-ordering bugs because the bootstrap’s install step becomes a no-op. To catch these before CI, run dagger core engine local-cache prune (or use a fresh cacheVolume name) and re-test locally.

References

This post was written with AI assistance.