Reproducible Terminal Dev Environment: direnv + mise [2026]
A repo-first, cross-shell setup for a reproducible terminal dev environment using direnv + mise on macOS, Linux, and WSL—plus secrets patterns and troubleshooting.
A “works on my machine” bug in 2026 is usually not a bug. It’s a shell state problem.
You pull a repo. node points to Homebrew on macOS. Your teammate’s python comes from pyenv. CI uses something else entirely. Half the env vars live in ~/.zshrc and the other half are copy/pasted into Slack every time someone rotates a token. Then someone suggests devcontainers or Nix and you quietly back away because you just wanted npm test to run.
This post is a reproducible terminal dev environment direnv mise setup I actually recommend for teams that want per-project shells that just work across macOS, Linux, and WSL, without going full container or full Nix. The contract lives in the repo: mise.toml + .envrc + a local-only secrets file.
Key takeaways
- A reproducible dev shell is a repo-level contract for tool versions, environment variables, and tasks, not a personal dotfiles hobby.
miseowns tool versions and tasks;direnvowns auto-loading env vars and secrets per directory.- Commit
mise.tomland a safe.envrc. Keep secrets in.envrc.local(gitignored) or encrypted with SOPS. - Cross-platform success comes down to PATH ordering and predictable shell init hooks (zsh and fish).
- Onboarding should be a one-liner. If it needs a wiki page, it’s already failing.
If your dev environment isn’t reproducible from the repo, it’s not an environment. It’s a personal ritual.
What makes a terminal dev environment “reproducible” (and what is not)?
A reproducible terminal dev environment is a setup where the repo defines the tools and environment needed to run the project, and any developer can get the same working shell by following a short bootstrap.

In practice, that means:
- Tool versions are pinned per project (Node 24.x, Python 3.13.x, Go 1.23.x, etc.).
- Env vars are loaded per directory (and unloaded when you leave).
- Secrets are never committed, but the workflow to obtain them is standardized.
- Common commands live as tasks so “build” means the same thing everywhere.
What it’s not:
- A screenshot of your dotfiles.
- A README that says “install Node and Python” with no versions.
- A
brew bundledump that breaks on Linux and WSL. - A devcontainer that’s “reproducible” only if Docker Desktop isn’t melting your laptop.
The boring truth: reproducibility is mostly about eliminating ambient state. That’s why terminal-first wins. Your shell is where the ambient state lives.
Why `mise` + `direnv` is the minimal stack that scales
I like this pairing because the responsibilities are clean.

`mise`: tool versions, env, and tasks (repo-owned)
mise is a polyglot tool manager and task runner. The official docs describe it as:
“One tool that manages dev tools, env vars, and tasks per project.” (mise documentation)
The part I care about most is that mise makes the repo the source of truth via mise.toml.
Concrete example from the mise homepage demo shows the shape of it:
mise use node@24 [email protected]- resulting pinned versions like
[email protected]and[email protected]
Those exact patch versions will change over time, but the point is: you can pin majors/minors and let mise resolve the exact install, or pin fully if you want maximum determinism.
`direnv`: auto-load env vars + secrets per directory (shell-owned)
direnv is a shell extension that automatically loads/unloads environment variables as you cd.
Its official definition is clear:
“direnv is an extension for your shell… that can load and unload environment variables depending on the current directory.” (direnv)
It uses an authorization model (direnv allow) so a random cloned repo can’t silently execute arbitrary env code.
Why they complement each other
misehandles tools and tasks that are safe to commit.direnvhandles per-directory activation and is excellent for secrets workflows.
Yes, mise can load env vars too. Yes, direnv can hack your PATH. The win is choosing one “owner” for each concern so debugging doesn’t become archaeology.
Setup in 7 steps (repo-first)
This is the reference implementation I’d put in a new repo today.

- Install `mise` on each machine (macOS/Linux/WSL).
- The mise docs show an install path via
curl https://mise.run | sh(mise documentation). If you’re in a locked-down org, use your package manager instead.
- The mise docs show an install path via
- Install `direnv` via your OS package manager.
- direnv is packaged widely; the docs call this out explicitly (direnv).
- Create `mise.toml` in the repo and commit it.
Minimum viable example (keep it small at first):
- tools:
node,python,go,rust(whatever your repo actually needs) - tasks:
dev,test,lint,build,typecheck
- Run `mise install` (or
mise use ...) to install the pinned tools. - Add `.envrc` to the repo (committed), but make it safe.
The best .envrc is boring:
- loads
.envrc.localif it exists - adds predictable PATH entries
- does not contain raw secrets
- Add `.envrc.local` to `.gitignore` and use it for machine/user secrets.
- On first entry, run `direnv allow`.
- The direnv demo shows the security prompt and
direnv allow .flow (direnv).
- The direnv demo shows the security prompt and
That’s it. The “reproducible” part is that steps 3, 5, and the README instructions live with the repo.
What is `mise.toml` supposed to contain?
mise.toml is your repo’s dev environment contract.
At minimum, I want three sections to exist conceptually:
1) Tools
Pin the languages and CLIs the repo needs.
Examples of “polyglot reality” I see constantly in 2026:
- Node for frontend tooling.
- Python for backend scripts, data tasks, or AI glue.
- Go for infra CLIs.
- Rust for performance-critical services.
This is where mise shines: one config file for 2 languages or 6. The docs highlight “1000+ tools” and a single configuration file (mise documentation).
2) Env (non-secret defaults)
Use this for safe defaults like:
APP_ENV=developmentLOG_LEVEL=debugPORT=3000
If an env var would hurt you if it leaked, it’s not a default. It’s a secret.
3) Tasks
Tasks are where teams get leverage.
A pattern that works:
mise run testis the canonical test command.- It can call
pytest,pnpm test,go test ./..., etc.
The objective isn’t “task runner purity”. It’s making the repo self-describing so onboarding doesn’t require tribal knowledge.
How to hook `direnv` into zsh and fish (side-by-side)
direnv supports multiple shells, including zsh and fish (direnv). In 2026 you’ll almost always have a split team: some people are on zsh (macOS default-ish), others are all-in on fish.
I’m not going to re-litigate shell wars, but I will say this: cross-shell reproducibility is mostly about minimizing shell-specific logic.
zsh
In zsh, the hook is typically added to ~/.zshrc. Keep it close to the top, before you start mutating PATH in clever ways.
fish
In fish, add the hook to your fish config (~/.config/fish/config.fish). fish users often have a lot of interactive sugar; that’s fine, but the direnv hook should stay early so it wins.
If you do nothing else, do this: ensure your team has a “Shell Setup” section in the repo README that contains two snippets, one for zsh and one for fish, and nothing else.
Secrets handling with direnv (secure patterns that don’t annoy people)
Most direnv tutorials stop at “export FOO=bar”. That’s not the problem you have.
The problem you have is:
- people committing secrets accidentally
- people keeping secrets in shell history
- people inventing 5 different secret-loading workflows
Here are patterns that hold up.
Pattern A: `.envrc` + `.envrc.local` (recommended default)
Commit .envrc. Gitignore .envrc.local.
The committed .envrc should:
- check if
.envrc.localexists - load it
- fail with a helpful message if a required secret is missing
Rules:
- No raw secrets in
.envrc. - No raw secrets in
mise.toml. .envrc.localnever leaves the machine.
Pattern B: Use encrypted secrets with SOPS + age
If you need team-shared secrets in a repo (or a separate config repo), use encryption. Mozilla SOPS is the go-to for this in a lot of teams.
I’m intentionally not linking random blog posts here. Use the official repo docs for your implementation and rotate keys like adults.
Pattern C: Pull secrets from a vault at shell activation time
If your org uses a secrets manager (1Password, Vault, AWS SSM), you can fetch or inject env vars on activation.
Two warnings:
- Don’t make
cdslow. If fetching secrets adds 500ms per prompt, people will disable it. - Don’t spray secrets into every subshell. Scope matters.
If you adopt this pattern, cache aggressively and prefer short-lived tokens.
macOS vs Linux vs WSL: the cross-platform reality checks
Cross-platform reproducibility isn’t “does it run”. It’s “does it run without everyone becoming a part-time shell debugger.”
Here are the issues that actually bite.
PATH ordering (the #1 failure mode)
You can install the right version of Node and still run the wrong node.
Common causes:
- Homebrew shims earlier in PATH than mise.
- Old
asdfshims lingering. - A language-specific manager (pyenv/rbenv) mutating PATH after activation.
What I do in team setups:
- pick one tool manager (mise)
- remove or disable the others for that repo
- document the expected
which node/which pythonoutput in the README
WSL gotchas
WSL is great, but it has two recurring annoyances in terminal-first workflows:
- SSH agent forwarding and key locations can be inconsistent between Windows and the WSL distro.
- Clipboard integration and opening URLs differs by terminal host.
The fix is not more tooling. The fix is writing down the exact prerequisites and keeping them short.
Shell init file drift
macOS engineers tend to have huge ~/.zshrc files. Linux engineers tend to have a bunch of stuff in .profile and .bashrc. fish users have config spread across functions.
Your repo contract should not care.
If your setup requires editing 6 different init files, it’s not a reproducible setup. It’s a migration project.
Troubleshooting matrix (what to check first)
When this setup breaks, it breaks in predictable ways. Here’s the order I debug in.
1) direnv isn’t loading at all
- Symptom:
.envrcchanges do nothing. - Check: did you add the shell hook? (zsh/fish)
- Check: did you restart the shell?
2) direnv says “not allowed”
- Symptom:
direnv: error .envrc is blocked. Run 'direnv allow'... - Fix: run
direnv allow .(the official demo shows this exact flow) (direnv).
3) Wrong tool version is executing
- Symptom:
node -visn’t whatmise.tomlexpects. - Check: PATH ordering.
- Check: are you using the right shell session (login vs non-login)?
4) Conflicting shims
- Symptom:
which pythonpoints to pyenv/asdf/brew even though mise installed Python. - Fix: disable the other manager for that shell, or at least move it after mise in PATH.
5) Slow shell startup
- Symptom: prompt latency after enabling direnv.
- Cause: heavy work in
.envrc(network calls, large scripts). - Fix: keep
.envrcminimal. If you need secrets from a vault, fetch once and cache.
Teammate onboarding: prerequisites + one-liner bootstrap
Onboarding is where “reproducible” proves itself.
My rule: a teammate should go from fresh machine to passing tests in 15 minutes, without a call.
Your repo should include:
- Prereqs:
git, a shell (zsh or fish), and the package manager you expect (Homebrew/apt). - Bootstrap: a single script that installs mise + direnv if missing, runs
mise install, and prints “next steps”.
Keep it brutally simple. The bootstrap isn’t about being clever. It’s about eliminating Slack-driven setup.
If you already have an internal dev bootstrap system, great. Still keep a repo-local script. People fork repos. CI clones repos. Future you will thank you.
Closing: the point isn’t the tools, it’s the contract
In 2026, engineers ship across more languages, more operating systems, and more environments than ever. Pretending everyone will converge on the same dotfiles is fantasy.
A repo-first setup with mise.toml + .envrc is a small, practical contract that scales from solo projects to teams. It’s not as “pure” as Nix and not as “isolated” as containers. It’s the boring middle that actually gets adopted.
My prediction: teams that treat dev shells as part of the codebase will onboard faster than teams that treat dev shells as personal preference. If you want to feel the difference, pick one repo this week and make the contract real.
Photo by SAYAN MONDAL on Unsplash.
Kunal Ganglani (2026, August 8). Reproducible Terminal Dev Environment: direnv + mise [2026]. Kunal Ganglani. Retrieved August 8, 2026, from https://www.kunalganglani.com/blog/reproducible-terminal-dev-environment



Comments