~ 12 min read

How to Turn a Custom Terminal Setup into Portable Dotfiles with chezmoi

share this story on
A practical guide to turning a custom macOS terminal setup into portable, reviewable dotfiles with chezmoi, Homebrew Bundle, zsh modules, Starship, and a public-safe repository workflow.

A terminal setup becomes real infrastructure the moment you want to restore it on another machine. A nice prompt, shell aliases, package-manager helpers, Git signing, and app configs are not isolated tweaks anymore; they are part of how you build software every day.

The mistake is treating dotfiles as either a random backup folder or a full mirror of your home directory. Both approaches fail in different ways. A backup folder goes stale. A full mirror leaks history, tokens, caches, and machine-specific state. The better approach is to define the terminal environment as a small, reviewed, reproducible system.

This tutorial walks through that system using chezmoi for dotfile state, Homebrew Bundle for packages, and a scoped public repository for the files that are safe and useful to restore.

Background: Dotfiles Are Configuration, Not Your Whole Home Directory

Dotfiles look deceptively simple. You can put ~/.zshrc in Git and call it a day, but the moment you add terminal tooling, the boundary gets blurry. Should you track ~/.config/atuin/config.toml but not Atuin history? Should you track ~/.gnupg/gpg-agent.conf but not GPG keys? Should generated fzf shell scripts live in the repo? The answer is less about tools and more about ownership.

The practical model I prefer is:

  1. Track files I intentionally authored or curated.
  2. Track package manifests needed to install those tools.
  3. Exclude secrets, history, caches, generated state, local-only environment variables, and auth sessions.
  4. Validate the repo before pushing, especially when it is public.

That model fits well with chezmoi. Chezmoi stores the desired state of dotfiles in ~/.local/share/chezmoi and applies them back into the home directory. This is different from using the home directory itself as a Git working tree. You get a clean source repo, while apps still read the real files from their expected locations.

For this setup, the initial scope was intentionally narrow:

  • zsh config split into small files under ~/.config/zsh
  • Ghostty terminal config under ~/.config/ghostty/config
  • Starship prompt config under ~/.config/starship.toml
  • A Starship helper script for Node.js package-manager detection
  • Later additions for Git, GPG, npm, Finicky, and selected GitHub Copilot CLI aliases

That is enough to restore the actual developer experience without copying the messier parts of a long-lived home directory.

How It Works

At a high level, the workflow has three layers:

Home directory files
  ~/.zshrc
  ~/.config/starship.toml
  ~/.config/ghostty/config
  ~/.gitconfig

chezmoi source state
  ~/.local/share/chezmoi/dot_zshrc
  ~/.local/share/chezmoi/dot_config/starship.toml
  ~/.local/share/chezmoi/dot_config/ghostty/config
  ~/.local/share/chezmoi/dot_gitconfig

GitHub repository
  git commit
  git push
  restore on another machine

The important detail is that the source repo is not the runtime location. Ghostty still reads the configuration file that the Ghostty configuration docs expect. zsh still reads ~/.zshrc. Git still reads ~/.gitconfig. Chezmoi gives you a controlled way to keep those live files and the source repo in sync.

For example, this command copies the live file into the chezmoi source tree:

chezmoi add ~/.config/ghostty/config

Chezmoi stores that as:

~/.local/share/chezmoi/dot_config/ghostty/config

When you run:

chezmoi apply

chezmoi writes the source state back to:

~/.config/ghostty/config

The same pattern works for ~/.zshrc, ~/.gitconfig, ~/.npmrc, and most plain-text configuration files.

Step 1: Start with a Small Scope

The first decision is what not to track. A dotfiles repo should start with a narrow allowlist, not a broad sweep of ~/.

For a terminal setup, this is a good first batch:

chezmoi add ~/.zshrc
chezmoi add ~/.config/zsh
chezmoi add ~/.config/starship.toml
chezmoi add ~/.config/starship/package-manager.js
chezmoi add ~/.config/ghostty/config

Then expand only when a file passes a quick review:

chezmoi add ~/.gitconfig
chezmoi add ~/.gitignore
chezmoi add ~/.config/git/ignore
chezmoi add ~/.gnupg/gpg.conf
chezmoi add ~/.gnupg/gpg-agent.conf
chezmoi add ~/.npmrc
chezmoi add ~/.config/gh-cp/aliases.json
chezmoi add ~/.finicky.js

This produces a source tree shaped like this:

.chezmoiignore
.gitignore
Brewfile
README.md
dot_config/
  ghostty/config
  git/ignore
  gh-cp/aliases.json
  starship.toml
  starship/package-manager.js
  zsh/
    aliases.zsh
    completions.zsh
    env.zsh
    functions.zsh
    npm-completion.zsh
    tools.zsh
dot_finicky.js
dot_gitconfig
dot_gitignore
dot_zshrc
private_dot_gnupg/
  gpg-agent.conf
  gpg.conf
private_dot_npmrc
scripts/bootstrap-macos.sh

Chezmoi’s file naming is deliberate: dot_gitconfig maps to ~/.gitconfig; private_dot_npmrc maps to ~/.npmrc with private file permissions; dot_config/starship.toml maps to ~/.config/starship.toml.

Step 2: Split zsh into Modules

A giant .zshrc is easy to append to and hard to reason about. The useful move is to keep ~/.zshrc as a small loader and put the real configuration into focused files.

export ZSH_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/zsh"

source "$ZSH_CONFIG_DIR/env.zsh"
source "$ZSH_CONFIG_DIR/aliases.zsh"
source "$ZSH_CONFIG_DIR/functions.zsh"
source "$ZSH_CONFIG_DIR/completions.zsh"
source "$ZSH_CONFIG_DIR/tools.zsh"

That split gives each concern a home:

  • env.zsh for LANG, EDITOR, PATH, EZA_COLORS, and local environment loading.
  • aliases.zsh for ls, ll, cat, and command shortcuts.
  • functions.zsh for shell functions.
  • completions.zsh for fpath, compinit, npm completion, and pnpm completion.
  • tools.zsh for Starship, zoxide, Atuin, fzf, zsh-autosuggestions, and zsh-syntax-highlighting.

One small but important guard belongs in interactive tool setup:

if [[ ! -t 1 ]]; then
  return
fi

That prevents interactive shell integrations from running in non-interactive contexts. It is a quiet reliability improvement for scripts, CI shells, and editor subprocesses.

The other local-only pattern is an explicit private include:

if [[ -f "${ZSH_CONFIG_DIR:-$HOME/.config/zsh}/env.local.zsh" ]]; then
  source "${ZSH_CONFIG_DIR:-$HOME/.config/zsh}/env.local.zsh"
fi

The repo tracks env.zsh; it does not track env.local.zsh.

Step 3: Keep the Prompt Fancy but Testable

The prompt in this setup uses Starship custom commands to go beyond the built-in modules. The visual design uses connected rounded segments for user, directory, Git branch, Node.js version, package manager, and a second-line time/status segment.

The tricky part was the package manager segment. A Node.js project can imply npm or pnpm in several ways, and a prompt should not guess too early. The detection order became:

  1. package.json devEngines.packageManager
  2. package.json engines.npm or engines.pnpm
  3. lockfiles such as pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json
  4. fallback to npm

The Starship module is intentionally small and delegates the logic to a Node.js script:

[custom.package_manager]
command = "node ~/.config/starship/package-manager.js"
style = "bg:#94E2D5 fg:#11111B"
format = '[](fg:#94E2D5 bg:#A6E3A1)[ $output ]($style)[](#94E2D5)'
detect_files = [
  "package.json",
  "pnpm-lock.yaml",
  "package-lock.json",
  "npm-shrinkwrap.json",
]
detect_folders = ["node_modules"]

The script itself does three things:

  • walks upward to find the nearest project marker;
  • decides whether the project wants npm or pnpm;
  • displays the installed package-manager version from the host.

In practice, this keeps the prompt useful without making it expensive. The installed version lookup is cached in /tmp, and Starship’s command_timeout remains a safety net for custom commands.

Step 4: Use a Brewfile for Tools, Not Memory

Dotfiles restore configuration, but they do not install the tools that read that configuration. That is where a Brewfile earns its place.

Homebrew Bundle lets you encode the desired installed state as text. The docs call this a declarative interface for Homebrew packages and casks, which is exactly what a terminal bootstrap needs.

brew "chezmoi"
brew "git"
brew "gh"
brew "gnupg"
brew "pinentry-mac"
brew "starship"
brew "atuin"
brew "zoxide"
brew "eza"
brew "bat"
brew "fzf"
brew "ripgrep"
brew "tree"
brew "fnm"
brew "pnpm"
brew "pyenv"
brew "openjdk@17"
brew "zsh-autosuggestions"
brew "zsh-syntax-highlighting"
brew "zsh-completions"

cask "ghostty"
cask "font-fira-code-nerd-font"
cask "finicky"

One subtle choice is whether bootstrap should upgrade existing packages. By default, brew bundle may upgrade outdated software. For a restore script, that can be surprising, so the bootstrap uses HOMEBREW_BUNDLE_NO_UPGRADE=1.

#!/usr/bin/env bash
set -euo pipefail

SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

if ! command -v brew >/dev/null 2>&1; then
  echo "Homebrew is required before bootstrapping these dotfiles."
  echo "Install it from https://brew.sh/, then run this script again."
  exit 1
fi

HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle install --file "$SOURCE_DIR/Brewfile"
chezmoi apply

This keeps bootstrap focused on missing dependencies. Upgrades become an intentional maintenance task, not a side effect of restoring shell config.

Step 5: Decide What Not to Track

The strongest dotfiles habit is building an exclusion list before you feel tempted to publish everything.

This setup intentionally does not track:

  • ~/.config/zsh/env.local.zsh
  • GitHub CLI auth hosts
  • SSH keys and SSH private config details that reveal internal hosts
  • GPG private keys, trust DBs, and keyrings
  • npm auth tokens
  • shell history
  • Atuin history and sync/session data
  • zoxide databases
  • generated backups
  • Ghostty generated files under ~/.config/ghostty/auto/
  • app caches and large model/runtime state

The repo-level .chezmoiignore also prevents helper files from being applied into the home directory:

README.md
Brewfile
scripts/**

That means README.md, Brewfile, and scripts/bootstrap-macos.sh live in the source repo, but chezmoi will not copy them into ~/README.md, ~/Brewfile, or ~/scripts.

For public repos, I also like a quick secret-word scan before pushing:

rg -n -i \
  "(token|secret|password|passwd|private[_-]?key|client[_-]?secret|auth[=:]|gho_|sk-|BEGIN .*PRIVATE KEY)" \
  ~/.local/share/chezmoi

This is not a full secret scanner, but it catches obvious mistakes. If it flags a variable reference like $KIMI_API_KEY, review it manually. A variable name is not the same thing as a secret value.

Trade-offs & Alternatives

There are several good ways to manage dotfiles. The right choice depends on how much structure you want and how much machine-specific behavior you expect.

ApproachBest ForTrade-off
Plain Git repo with install scriptSmall setups and people who want total controlYou own linking, idempotency, ignores, templates, and safety checks
GNU StowSymlink-first dotfiles with simple package directoriesEasy to understand, but symlink behavior can get awkward around generated files
yadmGit-style management directly around $HOMEConvenient if you want Git semantics everywhere, but the home directory can feel noisy
DotbotDeclarative symlinks and setup commandsNice for bootstrap scripts, but still more symlink-oriented
chezmoiMulti-machine dotfiles with templates, diffs, and controlled applyAnother tool to learn, but it handles the boring lifecycle well

The reason chezmoi won here is not that it is clever. It won because it supports the daily workflow:

vim ~/.finicky.js
chezmoi add ~/.finicky.js
chezmoi diff
chezmoi cd
git status
git add .
git commit -m "Update Finicky config"
git push

You can also edit the source file first:

chezmoi edit ~/.finicky.js
chezmoi apply

For app configs that need immediate reload behavior, I prefer editing the live file first, then adding it back to chezmoi once it works.

Validation & Measurement

A dotfiles repo needs checks that answer three questions:

  1. Does chezmoi know the right files?
  2. Would applying the source state change the live machine unexpectedly?
  3. Are the package dependencies satisfied?

These commands cover that baseline:

chezmoi managed
chezmoi ignored
chezmoi diff
chezmoi status
HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle check --file ~/.local/share/chezmoi/Brewfile --verbose

Expected results:

  • chezmoi managed lists only the intended config files.
  • chezmoi ignored includes repo helpers such as README.md, Brewfile, and scripts.
  • chezmoi diff is empty after syncing live files into source state.
  • chezmoi status is empty when source and live files match.
  • brew bundle check prints that dependencies are satisfied.

For shell changes, start a new login shell and check the core integrations:

zsh -lic 'command -v starship; command -v zoxide; command -v atuin; echo $EDITOR'

For Git signing, the Git signing documentation is a useful reminder of the boundary: user.signingkey is a key ID in config, while the private key material belongs in GPG’s private keyring and should not be committed.

Security & Performance Considerations

The security risk in dotfiles is not only secrets. Public configuration can reveal usernames, email addresses, hostnames, internal domains, browser-routing rules, repository names, or tool choices. Some of that may be acceptable for a public developer profile. Some of it should stay private. Treat the push to a public dotfiles repo as a publication step, not a backup step.

The practical rule is to separate configuration from state:

  • Track ~/.gnupg/gpg-agent.conf; do not track ~/.gnupg/private-keys-v1.d.
  • Track ~/.config/atuin/config.toml only if you changed behavior; do not track Atuin history, sessions, or encryption keys.
  • Track ~/.config/zsh/env.zsh; do not track env.local.zsh.
  • Track ~/.npmrc only after checking for auth tokens.
  • Track generated shell scripts only when you have intentionally modified them.

Performance matters too. Shell startup can quietly degrade as you add tools. In this setup, the heavy integrations are guarded, completion initialization runs once, and Starship custom commands use detection rules so they only run in relevant directories.

The package-manager prompt script also caches installed versions. A prompt command that shells out to npm --version and pnpm --version on every render sounds small until it runs hundreds of times a day.

Troubleshooting & Pitfalls

Symptom: brew bundle check wants to upgrade a lot of packages.
Cause: brew bundle may upgrade outdated software by default.
Fix: use HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle install --file Brewfile for bootstrap, and run upgrades intentionally later.

Symptom: chezmoi wants to create ~/README.md or ~/Brewfile.
Cause: repo helper files are not ignored from apply.
Fix: add them to .chezmoiignore.

Symptom: a public repo review finds personal URLs or auth material.
Cause: too broad an allowlist, or a config file that mixes behavior with private state.
Fix: remove it from chezmoi, rewrite history if it was pushed, rotate any exposed secrets, and consider a private repo or chezmoi templates.

Symptom: Starship shows an empty colored segment.
Cause: a separator or module is unconditional while the module it connects to is absent.
Fix: make separators conditional with custom modules, detect_files, detect_folders, or commands that check the current directory state.

Limitations & Future Work

This setup is intentionally macOS-first. The Brewfile assumes Homebrew and includes macOS casks such as Ghostty, Finicky, and a Nerd Font. Chezmoi can support Linux and Windows machines too, but that would require templates or OS-specific package sections.

It also does not yet encrypt secrets in the dotfiles repo. Chezmoi supports several secret-manager and encryption workflows, but the simpler starting point is to keep secrets out of the repo entirely. That is easier to audit and enough for many terminal setups.

Finally, generated configs need case-by-case decisions. fzf and Atuin are installed and integrated, but their default-generated files or history state are not automatically worth tracking. Add them only when you have made intentional changes you want to preserve.

FAQ

Should I edit the live file or the chezmoi source file?

Both workflows are valid. For files that apps reload directly, edit the live file first, verify behavior, then run chezmoi add <file>. For planned changes where you want to preview before applying, use chezmoi edit <file> followed by chezmoi diff and chezmoi apply.

Is it safe to commit user.signingkey in .gitconfig?

Usually yes. user.signingkey is a public key ID or fingerprint, not the private key. The private key material lives under GPG’s key storage and should not be tracked. Still, publishing the key ID associates that signing identity with the dotfiles repo, so decide whether that is acceptable for your public profile.

Should Atuin config be committed?

Commit Atuin config only if it contains intentional behavior changes. Do not commit Atuin databases, history, encryption keys, or session files. If the file is mostly generated defaults, leave it out until you have a real reason to preserve it.

Should fzf shell files be committed?

Usually not if they are generated by the fzf installer and you have not modified them. Install fzf through the Brewfile, source it from zsh when present, and avoid freezing generated installer output into your dotfiles repo.

Should the dotfiles repo be public or private?

Public is fine if you treat it as a publication artifact and review every file. Private is safer if your configs contain internal hostnames, personal routing rules, customer names, or anything you do not want indexed. Even private repos should not contain long-lived tokens or private keys.

Next Steps

Start with a narrow allowlist: shell loader, terminal config, prompt config, and one package manifest. Add more files only after reviewing whether they are authored config, generated output, local state, or secret-adjacent data.

Run chezmoi managed, chezmoi diff, and brew bundle check before each push. Those checks are cheap, and they catch the most common mistakes before your dotfiles become someone else’s copy-paste problem.

For a concrete reference, the public dotfiles repo from this setup lives at github.com/lirantal/dotfiles. Treat it as an example of scope and workflow, not as a universal template.

Follow me on X/Twitter at @liran_tal for more developer tooling and agentic development notes, and explore related projects on GitHub.