I’ve set up new machines enough times to know the pattern: it goes fine until it doesn’t. You think you remember your .zshrc, and you mostly do, and then three weeks later you’re wondering why fh doesn’t exist and slowly piecing together what you’d built.
Dotfiles in a git repo solves this. Your config files — .zshrc, .gitconfig, .ssh/config, and the rest — are versioned, and restoring them on a new machine takes minutes instead of an afternoon of archaeology.
Why chezmoi
The classic approach is symlinks: tools like stow or yadm link files from your home directory into the repo, so edits are tracked immediately. It works, but permissions, secrets, and machine-specific config all need extra glue.
I use chezmoi. A few things sold me on it:
Copy, not symlink. chezmoi physically copies files to their target locations. Permissions are handled cleanly, and you don’t get surprised when a tool follows a symlink somewhere unexpected.
Templating. Config files can be Go templates with variables you define at setup time. My shell adapts based on which OS and tools are present — no runtime detection in shell configs, no separate branches.
Built-in age encryption. Secrets live encrypted in the public repo and are decrypted on apply. No separate secret store to maintain.
One-command bootstrap. On a fresh machine with nothing installed, one command goes from zero to fully configured.
The bootstrap
macOS
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply boranuzunLinux
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b ~/.local/bin init --apply boranuzunThe Linux variant installs chezmoi to ~/.local/bin first, then runs the same init flow. Both commands do the same thing end-to-end:
The run_once_before_ scripts only execute once per machine — chezmoi tracks them by content hash. They run in lexicographic order:
run_once_before_10-install-homebrew.sh.tmpl— installs Homebrew on macOS (no-op on Linux)run_once_before_20-install-packages.sh.tmpl—brew bundleon macOS;apt/pacmanon Linux
After that, chezmoi applies every tracked config file to its target location, decrypting secrets along the way.
Setup prompts
On first apply, chezmoi asks four questions:
| Prompt | Stored as |
|---|---|
| Email address | data.email |
| Full name | data.name |
| GPG key ID for git signing (leave empty to disable) | data.gpgKeyId |
| Is OrbStack installed? | data.hasOrbStack |
Answers are saved to ~/.config/chezmoi/chezmoi.toml and never asked again.
Post-bootstrap manual steps
Two things aren’t automated. You do these once after bootstrapping:
- GitHub CLI —
gh auth login - (macOS) 1Password SSH agent — enable in 1Password settings; the SSH config already points to it
Secrets without paranoia
Three files contain sensitive information: git config (name and email), SSH config (host definitions), and GitHub CLI auth tokens. They can’t live as plaintext in a public repo.
chezmoi integrates with age. Each secret file is encrypted with my age public key and committed as a .age file. chezmoi decrypts them on apply using the private key at ~/.config/chezmoi/key.txt. That key lives in 1Password and needs to be placed at that path before bootstrapping — it’s the one manual prerequisite.
Editing a secret works exactly like editing any other tracked file:
# Opens the decrypted content in $EDITOR, re-encrypts on savechezmoi edit ~/.config/git/configNo manual encrypt/decrypt steps. chezmoi handles the round-trip.
Templated configs
I use OrbStack on my Mac but not on Linux. On Linux I prefer native package managers (apt, pacman) over Homebrew. Rather than maintaining separate branches, Go template conditionals handle this using .chezmoi.os. The same template variables drive git config too — if you supply a GPG key ID at setup time, commit.gpgsign = true and user.signingKey are set automatically; leave it empty and signing is disabled entirely.
My ~/.zprofile source file (dot_zprofile.tmpl) looks like this:
source ~/.profile
{{- if eq .chezmoi.os "darwin" }}eval "$(/opt/homebrew/bin/brew shellenv)"{{- end }}
{{ if and (eq .chezmoi.os "darwin") .hasOrbStack -}}# OrbStacksource ~/.orbstack/shell/init.zsh 2>/dev/null || :{{- end }}
export PATH="$PATH:$HOME/.local/bin"The Homebrew eval only renders on macOS. The OrbStack block only renders on macOS with hasOrbStack set. On Linux, the rendered file is just those two lines. All platform logic stays in templates — shell configs never check the OS at runtime.
The same pattern applies throughout: aliases.zsh.tmpl, env.zsh.tmpl, and omz.zsh.tmpl all gate OS-specific blocks with .chezmoi.os.
The shell setup
The Zsh configuration is split into focused modules under ~/.config/zsh/:
| File | Purpose |
|---|---|
env.zsh.tmpl | $PATH (Homebrew path on macOS, ~/.local/bin on Linux), $EDITOR, locale, Bun, Starship, zoxide |
history.zsh | History file size (1,000,000 entries), dedup settings |
fzf.zsh | fzf key bindings, bat/eza previews, yazi shell wrapper |
omz.zsh.tmpl | Oh My Zsh theme (none — Starship handles the prompt), plugins list |
aliases.zsh.tmpl | All shell aliases, OS-specific variants |
.zshrc | Sources all modules in order |
The tools:
- starship — cross-shell prompt with git status, language versions, and execution time. I picked this over Oh My Zsh themes because it works the same on every shell and every machine.
- zoxide —
cdthat learns. After a week it knows where you go andz projbeatscd ~/Documents/Coding/Github/projectevery time. - eza —
lswith colour, icons, and git status - bat —
catwith syntax highlighting - yazi — terminal file manager with image previews and
bat/ezaintegration - Ghostty — my terminal. Fast, native, and the config file is just text you can check in.
- fastfetch — system info display
The aliases I actually use:
| Alias | Expands to | What it does |
|---|---|---|
ls | eza --color --icons --long --header --git | Long listing with git status |
lt | eza --tree --level=2 --long --icons --git | Tree view, 2 levels deep |
ltree | eza --tree --level=2 --icons --git | Tree view, 2 levels, compact |
cd | z | zoxide smart directory jump |
ff | fastfetch | System info |
sysup | macOS: brew upgrade; mas upgrade; omz update / Arch: sudo pacman -Syu; omz update / Debian: sudo apt update && sudo apt upgrade -y; omz update | Update everything for your platform |
buc | brew upgrade --cask | Upgrade Homebrew casks only (macOS) |
fh | fc -l 1 | tac | fzf | Fuzzy search through full shell history |
gh-create | gh repo create --private --source=. --remote=origin && git push -u --all && gh browse | Create a private GitHub repo and push the current directory |
opc | opencode | AI coding assistant |
cm | chezmoi | chezmoi shortcut |
rr | source ~/.zshrc | Reload shell config without opening a new terminal |
ql | qlmanage -p | Quick Look preview (macOS only) |
Day-to-day workflow
After the initial bootstrap, chezmoi mostly disappears. To edit a tracked file:
# Opens the source file in $EDITOR and applies on savechezmoi edit ~/.zshrc
# Or edit and apply immediatelychezmoi edit --apply ~/.zshrcTo preview what would change before applying:
chezmoi diffWhen I want a change on other machines:
cd ~/.local/share/chezmoigit add -A && git commit -m "chore: update aliases"git push
# On any other machine (Mac or Linux):chezmoi updatechezmoi update pulls and applies in one step. Same flow as the initial bootstrap, just incremental.
The repo
The full setup is at github.com/boranuzun/dotfiles. Steal whatever’s useful.