Nam Hoai NguyenMy taking notes

Syncing dotfile Across Machines with Chezmoi

Stop reconfiguring your machine from scratch on every new machine.

Nam-Hoai Nguyen
added
updated

The problem

Every time, When I got a new Macbook / Linux Machine (an office work, a VPS), I end up spending hours setting up terminal from scratch. Installing zsh, zinit, git, configuring SSH keys, setting up plugins, aliases and shell functions…
I tried solving this with a bootstrap script. It worked well enough, it installed everything: brew, git, docker , etc. But installing programs is only half the battle. It didn't restore my configuration files. I still had to set up nvm, and redefine all my aliases and functions from scratch every time.

Why not stow

With GNU Stow, configuration files are simply symlink, allowing immediate config updates. However, this introduces an annoying issue: authentication files for CLI tools like Claude Code and GitHub Copilot are also stored within the symlink directories.
Consequently, every time I push to Git, I am forced to review the changes to ensure no secrets are included. On several occasions, I have accidentally pushed these sensitive files to GitHub and had to reset those commits.
The other pain point: when I updated a config on one machine, I had to manually mirror that change on all the others. If I forgot, the machines would quietly drift apart, and I'd end up with different behavior depending on where I was working
Plenty of dotfiles managers exist. chezmoi wins on three fronts:
  1. Go templates: the same file renders differently per OS, no need to maintain three .gitconfig variants
  1. Native encryption: age and gpg are first-class, so secrets can live in a public repo
  1. onchange scripts: the Homebrew bootstrap only re-runs when the package list actually changes

References

Introducing chezmoi

Chezmoi is an open-source tool managing your dotfiles across multiple machine, securely.
The concept is simple: you tell chezmoi which config files to tack, and it stores them in a Git repository that you push to Github (or any remote). Frome there, syncing your config to a new machine is just a matter of cloning and applying.
On a new machine, I either run the one line:
chezmoi init --apply $GITHUB_USERNAME

# or
chezmoi init --apply https://github.com/$GITHUB_USERNAME/dotfiles
Or after my bootstrap script install everything:
chezmoi init
chezmoi apply
When I update a config on a config on one machine:
chezmoi add ~/.zshrc
chezmoi edit ~/.zshrc
chezmoi re-add
Pushed to Github:
chezmoi cd
git commit -m 'chore: update zsh config'
git push
Automatically commit and push changes to your repo:
[git]
    autoCommit = true
    autoPush = true
On another machine will pull the latest changes from your repo
chezmoi update
chezmoi git pull -- --autostash --rebase && chezmoi diff
If you happy with changes, then you can run to apply them:
chezmoi apply
Restart the terminal and it’s done. This has been especially useful when spinning up AWS EC2 instances. What used to take hours now takes minutes.
Documentation: User Guide

Filename Attribute system

Chezmoi uses filename prefixes to encode behavior. The repo layout itself is the manifest - no separate config needed
Prefix
Effect
Example
dot_
Target is a hidden file
dot_zshrc~/.zshrc
private_
User-only permissions (0600)
private_dot_ssh.ssh
executable_
Set executable bit
executable_bin_foo
encrypted_
age/gpg encrypted
encryped_dot_env
symlink_
Create a symlink
symlink_dot_bashrc
readonly_
Strips write permissions
readonly_dot_config.yaml
.tmpl suffix
Run through template engine
dot_gitconfig.tmpl

Template for Machine Differences

This is chezmoi’s killer feature.
[user]
	name = {{ .username | quote }}
	email = {{ .email | quote }}
[core]
	symlink = true
	paper = delta
	
dot_config/git/config.tmpl
.username , .email come from ~/.config/chezmoi/chezmoi.yaml . So each machine can have its own value.
If you want to add the prompt input to enter the value for each machines
{{- $email := "" -}}
{{- if hasKey . "email" -}}
{{-   $email = .email -}}
{{- else -}}
{{-   $email = promptString "Email address" -}}
{{- end -}}

{{- $username := "" -}}
{{- if hasKey . "username" -}}
{{-   $username = .username -}}
{{- else -}}
{{-   $username = promptString "Username" -}}
{{- end -}}

data:
  email: {{ $email | quote }}
  username: {{ $username | quote }}
.chezmoi.yaml.tmpl
On apply, chezmoi strips the tmpl and writes a clean .config/git/config
Built-in variables reach for constantly:
{{ .chezmoi.os }} # darwin/linux/windows
{{ .chezmoi.osRelease }} # information from /etc/os-release
{{ .chezmoi.arch }} # amd64/arm64
{{ .chezmoi.hostname }} # machine name
{{ .chezmoi.username }} # login user
{{ .chezmoi.homeDir }} # home directory
Preview a template without applying:
chezmoi execute-template < dot_config/git/config.tmpl

Encrypt for secret configuration

Your dotfiles repo maybe public, but it can contains a SSH key, Database password backup, token,… Those must be encrypted with age (or gpg) before they ever hit a commit
Generate an age private key encrypted with a passphrase in the file key.txt.age with the command:
chezmoi age-keygen | chezmoi age encrypt --passphrase --output=key.txt.age
Public key: age1examplepublickeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Enter passphrase (leave empty to autogenerate a secure one):
Confirm passphrase:

# add key.txt.age to chezmoiignore so that chezmoi does not try to create it
echo key.txt.age >> .chezmoiignore
Configure chezmoi to decrypt the passphrase-encrypted private key
cat > run_onchange_before_decrypt-private-key.sh.tmpl <<EOF
#!/bin/sh

if [ ! -f "${HOME}/.config/chezmoi/key.txt" ]; then
    mkdir -p "${HOME}/.config/chezmoi"
    chezmoi age decrypt --output "${HOME}/.config/chezmoi/key.txt" --passphrase "{{ .chezmoi.sourceDir }}/key.txt.age"
    chmod 600 "${HOME}/.config/chezmoi/key.txt"
fi
EOF
Configure chezmoi.yaml.tmpl :
encryption: "age"
age:
  identity: "{{ .chezmois.source }}/chezmoi/key.txt"
  recipient: "age1examplepublickeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Add file with --encrypt arguments
chezmoi add --encrypt ~/.ssh/id_ed25519
The repo only ever stores private_dot_ssh/encrypt_private_id_ed25519.age - opaque cipher text. On apply decrypts using ~/key.txt and writes the plain text to the target.
If you want to add the key.txt to dotfiles repo. You have to the encrypt with the passphrase
age -p ~/key.txt > ~/key.txt.age
chezmoi add ~/key.txt.age

Command Cheatseat

chezmoi add ~/.zshrc       # track an existing file
chezmoi add --encrypt      # track encrypted
chezmoi edit               # edit the source file directory
chezmoi diff               # show pending changes
chezmoi apply              # apply the changes dotfiles
chezmoi apply --dry-run -v # preview without writing
chezmoi cd                 # go to chezmoi source directory
chezmoi update             # pull latest update + apply 
chezmoi doctor             # check health

My Github repo

I’m sure there are other ways to manage dotfiles , but this is what’s been working for me. Let me known how you handle yours.