Johannes Villmow

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:

  1. install-homebrew.sh detects or installs Homebrew, runs brew doctor,
  2. install-packages.sh iterates over the TOML lists, installs core tools, dev tools, GUI apps (macOS only), and fonts,
  3. post-install.sh sets zsh as the default shell, installs Node via nvm, and configures the lazyvim starter template for nvim.

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.