Managing Dotfiles with Chezmoi
2026-04-10
When I train machine learning models, I spend a lot of time on remote
servers. SSH into a GPU machine, and I'm greeted by a bare shell with none of my
aliases, no familiar tmux and editor config, and no keybindings.
It never quite feels like home.
That's why I use chezmoi to manage and automatically setup my dotfiles:
my zsh configuration, to setup nvim, my tmux layout, all of it. One
command, and any server looks and behaves exactly like my laptop. Everything
is up and running within minutes. Here's how I've set it up.
Configuration
The core idea is that all machine-varying configuration lives in a single TOML
file, .chezmoidata.toml:
[install]
coreTools = [
"git", "curl", "wget", "jq", "tree", "tmux", "neovim", "lazygit",
"fd", "bat", "eza", "zoxide", "fzf", "ripgrep",
]
devTools = ["nvm", "uv"]
guiApps = ["wezterm", "docker", "firefox", "visual-studio-code"]
fonts = ["font-ubuntu-mono-nerd-font", "font-hack-nerd-font"]
[versions]
node = "20"
nerdFontsVersion = "v3.0.2"
Installation scripts use homebrew to install the tools and apps listed here,
which is nice because it works on both macOS and Linux, admin and non-admin.
If I want to add a new tool, I just add it to the list and run chezmoi apply.
The install script re-runs, sees the new tool, and installs it. No need to write custom logic for each machine.
Templates
My machines run macOS and Linux (sometimes as root, sometimes not).
Luckily, chezmoi supports templates in the dotfiles, so I can write conditional logic
that only applies to the relevant machines. For example,
Homebrew is configured in different places on each:
{{- if eq .chezmoi.os "darwin" }}
if [ -d "/opt/homebrew" ]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [ -d "/usr/local/Homebrew" ]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
{{- else if eq .chezmoi.os "linux" }}
if [ -d "$HOME/.linuxbrew" ]; then
eval "$($HOME/.linuxbrew/bin/brew shellenv)"
elif [ -d "/home/linuxbrew/.linuxbrew" ]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
{{- end }}
This is a Go template that chezmoi evaluates at apply time. The rendered
.zshrc on each machine contains only the branches that actually apply.
Setting up new machines
First, I make sure in my main machine's .ssh/config that agent forwarding is enabled for all hosts.
Host *
UseKeychain yes
AddKeysToAgent yes
ForwardAgent yes
That way, when I run the following bootstrap command on a new machine, it can pull the dotfiles from my private GitHub repository without needing to set up SSH keys first.
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply git@github.com:villmow/dotfiles.git
# or the following when we dont have a password but sudo rights (e.g. on a new cloud VM)
NONINTERACTIVE=1 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply git@github.com:villmow/dotfiles.git
When I set up a new machine, the following command runs three scripts in order:
install-homebrew.shdetects or installs Homebrew, runsbrew doctor,install-packages.shiterates over the TOML lists, installs core tools, dev tools, GUI apps (macOS only), and fonts,post-install.shsetszshas the default shell, installs Node vianvm, and configures the lazyvim starter template fornvim.
External dependencies without submodules
Oh My Zsh and its plugins are pulled in via .chezmoiexternal.toml:
[".oh-my-zsh"]
type = "archive"
url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz"
stripComponents = 1
refreshPeriod = "168h"
[".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"]
type = "archive"
url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz"
stripComponents = 1
refreshPeriod = "168h"
Chezmoi downloads and extracts these archives, refreshing them weekly. No git submodules, no manual cloning.