Developer Environment Management: Version Managers and Dotfiles
Developer Environment Management: Version Managers and Dotfiles
Every developer eventually hits the same wall: your project needs Node 20 but your system has Node 22. You set up a new machine and spend a full day reinstalling tools and copying config files. Environment management solves all of this -- different tool versions per project, environment variables that activate automatically, and a setup that follows you across machines.
Here's how to set up environment management properly, which tools are worth using, and which ones to skip.
Version Managers: The Landscape
| Tool | Languages | Speed | Config File | Recommended? |
|---|---|---|---|---|
| mise | 20+ languages, any tool | Fast (Rust) | .mise.toml / .tool-versions |
Yes |
| asdf | 20+ languages via plugins | Moderate (shell) | .tool-versions |
Declining |
| nvm | Node.js only | Slow (shell) | .nvmrc |
Only if Node-only |
| fnm | Node.js only | Fast (Rust) | .nvmrc / .node-version |
Best Node-only option |
| pyenv | Python only | Moderate | .python-version |
Use uv instead |
| rbenv | Ruby only | Moderate | .ruby-version |
Use mise instead |
The short version: use mise. It handles everything the single-language managers do, but across all your languages and tools from one interface.
mise -- The Recommended Choice
mise (pronounced "meez," formerly rtx) is a polyglot version manager written in Rust. It manages Node.js, Python, Ruby, Go, Java, Terraform, and hundreds of other tools with a clean configuration format.
# Install mise
curl https://mise.run | sh
# Add to your shell
echo 'eval "$(mise activate zsh)"' >> ~/.zshrc
Basic Usage
mise use [email protected] # Install and pin to current directory
mise use [email protected]
mise use --global node@20 # Set global default
mise ls # List installed versions
mise ls-remote node # List available versions
Project Configuration
mise reads .mise.toml in your project root. This single file declares tool versions, environment variables, and project tasks.
# .mise.toml
[tools]
node = "20.11.0"
python = "3.12"
terraform = "1.7"
[env]
DATABASE_URL = "postgres://localhost:5432/myapp_dev"
NODE_ENV = "development"
[tasks.dev]
run = "npm run dev"
[tasks.test]
run = "npm test"
When you cd into the directory, mise automatically activates the right versions:
cd ~/projects/my-app
node --version # v20.11.0
cd ~/projects/other-app
node --version # v22.1.0
Why mise Over asdf
- Speed: Shell startup adds ~5ms versus ~200ms+ for asdf.
- Built-in env vars: The
[env]section replaces direnv for most use cases. - Task runner: Built-in tasks replace Makefiles for cross-language projects.
- No plugins to install: Just
mise use node@20-- noasdf plugin add nodejsstep. - Better errors: Clear messages about what went wrong and how to fix it.
asdf -- The Plugin Ecosystem
asdf is the original polyglot version manager. It works, has a massive plugin ecosystem, and is battle-tested. If your team already uses asdf, there's no urgent reason to migrate -- mise reads .tool-versions files, so you can migrate incrementally.
# Install and use
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
asdf plugin add nodejs
asdf install nodejs 20.11.0
asdf local nodejs 20.11.0
The main downside is performance. Shell scripts behind every shim add 200-500ms to startup time. If that bothers you, switch to mise.
Node.js Specifically: nvm and fnm
If you only manage Node.js versions, the dedicated managers are simpler.
nvm is the most widely used but adds 200-700ms to every new terminal:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
nvm install 20
nvm use 20
echo "20" > .nvmrc
fnm is a Rust-based drop-in replacement that starts in milliseconds:
curl -fsSL https://fnm.vercel.app/install | bash
fnm install 20
fnm use 20
fnm reads .nvmrc and .node-version files. If you only need Node, fnm is the right choice. If you also use Python, Go, or anything else, skip fnm and use mise.
direnv -- Per-Project Environment Variables
direnv automatically loads and unloads environment variables when you enter and leave a directory.
# Install and hook into shell
brew install direnv # macOS
sudo dnf install direnv # Fedora
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
Create a .envrc file in your project:
# .envrc
export DATABASE_URL="postgres://localhost:5432/myapp_dev"
export AWS_PROFILE="myapp-dev"
dotenv .env
PATH_add node_modules/.bin
Tip: Add .envrc to your global gitignore (~/.config/git/ignore), not the project's .gitignore. Different developers have different local credentials. Commit a .envrc.example template instead.
direnv vs. mise's [env]
mise's built-in [env] handles straightforward variable assignments without an extra tool. Use direnv only when you need conditional logic, layout commands (like layout python for automatic virtualenv activation), or loading from multiple files. For most projects, mise's [env] is enough.
Dotfiles Management
Your dotfiles -- .zshrc, .gitconfig, .config/nvim/ -- are the accumulated wisdom of how you work. Version-controlling them means setting up a new machine in minutes instead of hours.
chezmoi (Recommended)
chezmoi handles templating, secrets, and multi-machine differences.
sh -c "$(curl -fsLS get.chezmoi.io)"
chezmoi init
chezmoi add ~/.zshrc ~/.gitconfig ~/.config/nvim/
chezmoi apply
# Set up a new machine from your repo
chezmoi init --apply https://github.com/you/dotfiles.git
The killer feature is templating -- one set of dotfiles that adapts to different machines:
[user]
name = "Your Name"
email = "{{ if eq .chezmoi.hostname "work-laptop" }}[email protected]{{ else }}[email protected]{{ end }}"
GNU Stow
Simpler than chezmoi, uses symlinks, but no templating.
# ~/dotfiles/zsh/.zshrc, ~/dotfiles/git/.gitconfig, etc.
cd ~/dotfiles
stow zsh # Creates ~/.zshrc -> ~/dotfiles/zsh/.zshrc
stow git # Creates ~/.gitconfig -> ~/dotfiles/git/.gitconfig
Good if your dotfiles are identical across machines. If you need per-machine variation, use chezmoi.
Bare Git Repo
Tracks your home directory directly without symlinks.
git init --bare $HOME/.dotfiles
alias dotfiles='git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dotfiles config --local status.showUntrackedFiles no
dotfiles add ~/.zshrc
dotfiles commit -m "add zshrc"
This approach is clever but fragile -- it's easy to accidentally track files you don't want, and the bare repo mental model trips people up. Use chezmoi instead.
Dev Containers
Dev Containers define your entire development environment in a container. Everyone on the team gets the exact same tools, versions, and configuration.
// .devcontainer/devcontainer.json
{
"name": "My Project",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"features": {
"ghcr.io/devcontainers/features/go:1": { "version": "1.22" }
},
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint"]
}
}
}
Use them when: onboarding is painful, you have complex system-level dependencies, or you need CI/local parity. Skip them when: you're solo, dependencies are simple, or Docker overhead matters. Don't adopt them because they sound cool -- adopt them when consistency problems are actually costing you time.
Nix -- The Nuclear Option
Nix creates fully reproducible development environments where every dependency, down to the C library version, is pinned and deterministic.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system}; in {
devShells.default = pkgs.mkShell {
buildInputs = [ pkgs.nodejs_20 pkgs.python312 pkgs.go_1_22 ];
};
}
);
}
nix develop # Enter the dev shell with everything installed
When Nix is worth it: cross-platform reproducibility is critical, you have system-level C library dependencies that version managers can't handle, or large teams with heterogeneous machines. When it's overkill: most web development projects. The learning curve is steep -- Nix has its own functional language and famously difficult documentation. If your problem is "I need Node 20 and Python 3.12," mise solves that in 30 seconds. Try Nix on a side project first. mise + direnv covers 90% of the same ground with 10% of the complexity.
Recommended Setup
Here's what works for most developers:
- mise for all language versions and tool management
- mise's [env] for per-project environment variables
- chezmoi for dotfile management across machines
- Dev Containers only for team onboarding or complex system dependencies
- Nix only for bit-for-bit reproducibility requirements
Quick Start
# Install mise
curl https://mise.run | sh
echo 'eval "$(mise activate zsh)"' >> ~/.zshrc
# Set up global defaults
mise use --global node@20 [email protected]
# Configure a project
cd ~/projects/my-app
cat > .mise.toml << 'EOF'
[tools]
node = "20.11.0"
python = "3.12"
[env]
NODE_ENV = "development"
EOF
mise install
# Start managing dotfiles
sh -c "$(curl -fsLS get.chezmoi.io)"
chezmoi init
chezmoi add ~/.zshrc ~/.gitconfig
This gives you automatic version switching per project, environment variable management, and portable dotfiles -- all with fast shell startup and minimal configuration. Start here, and add complexity only when a specific problem demands it.