Two Strategies: Bare Repo vs Symlink Tree
The bare-repo method stores Git metadata in a non-standard directory, say ~/.dotfiles, while the working tree is your actual $HOME. You never copy or link files - they live where they belong. The symlink method keeps all configs in a single directory, for example ~/dotfiles, and links each file into place. Tools like GNU Stow, chezmoi, and yadm wrap the symlink approach with varying degrees of abstraction.
We prefer the bare-repo method for servers and personal machines where you control the environment. It has zero runtime dependencies beyond Git itself and leaves no footprint when you clone onto a new box. The symlink approach wins when you need templating - injecting machine-specific values into config files - which chezmoi handles well with its Go templates.
For the rest of this guide we use the bare-repo method as the primary path, with symlink notes where the approaches diverge.
# Bare repo method - initialize
git init --bare $HOME/.dotfiles
# Symlink method comparison - using GNU Stow
brew install stow
mkdir ~/dotfiles && cd ~/dotfiles
# Then stow packages per tool: stow zsh, stow vim, etc.
Setting Up the Bare Repository
Run git init --bare once. Then create an alias so you can run dotfiles commands from any directory without polluting normal git operations in your $HOME. Add this alias to your ~/.zshrc immediately - before you track anything else.
The --work-tree=$HOME flag tells Git to treat your home directory as the working tree. The --git-dir flag points to the bare repo. Together they let you run git add ~/.zshrc without cd-ing anywhere.
Set status.showUntrackedFiles to no. Without this, running dotfiles status floods output with every file in your home directory. Git only shows files you have explicitly tracked.
Set the remote before you add any files. On macOS in 2026, GitHub is the common choice, but any remote works. If you are hosting internal tooling and want a recognizable project identity, the team at nicename.me can help you register a clean domain for your self-hosted Gitea or Forgejo instance.
# Add to ~/.zshrc
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
# Source it immediately in this session
source ~/.zshrc
# Hide untracked files
dotfiles config --local status.showUntrackedFiles no
# Add your remote
dotfiles remote add origin git@github.com:youruser/dotfiles.git
# Start tracking files
dotfiles add ~/.zshrc ~/.gitconfig ~/.vimrc
dotfiles commit -m 'init: zsh gitconfig vim'
dotfiles push -u origin main
macOS-Specific Files Worth Tracking
macOS generates a lot of config that does not belong in version control: .DS_Store, .Spotlight-V100, .Trash. Add a global gitignore immediately or you will accidentally commit binary plist blobs.
Files that are worth tracking on macOS include ~/.zshrc, ~/.zprofile, ~/.gitconfig, ~/.gitignore_global, ~/.ssh/config (without private keys), ~/.config/karabiner/karabiner.json for keyboard remapping, and any Homebrew Brewfile you maintain.
System Integrity Protection on macOS 15 prevents writing to /System and /usr/bin, so you cannot track system-level configs there - only user-space files. That boundary is hard. Do not try to track /etc/hosts via dotfiles; use a provisioning tool for that.
For Homebrew, generate a Brewfile snapshot and commit it. This is the closest macOS equivalent to a package list on Linux. Running brew bundle on a new machine installs everything in one shot.
# Global gitignore for macOS
cat > ~/.gitignore_global << 'EOF'
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.localized
EOF
dotfiles config --global core.excludesfile ~/.gitignore_global
# Generate a Brewfile
brew bundle dump --file=~/Brewfile --force
dotfiles add ~/Brewfile
dotfiles commit -m 'brew: add Brewfile snapshot'
# On a new machine
brew bundle install --file=~/Brewfile
zsh Config Structure on macOS
macOS Sequoia ships zsh 5.9 as the default shell. Apple's zsh does not source ~/.zshenv on Terminal.app login shells in the same sequence as GNU/Linux distributions, so load order matters.
zsh reads files in this order for a login shell: /etc/zshenv, ~/.zshenv, /etc/zprofile, ~/.zprofile, /etc/zshrc, ~/.zshrc, /etc/zlogin, ~/.zlogin. For interactive non-login shells only ~/.zshrc is sourced. In practice on macOS you want PATH and environment variables in ~/.zprofile, and aliases and functions in ~/.zshrc.
Do not put PATH exports in ~/.zshrc if you run scripts that invoke zsh as a non-login shell - they will miss the PATH. We burned an hour on a CI runner because of this exact issue.
Keep your zsh config modular. Source from a directory rather than one monolithic file. This makes it easy to enable or disable sections per machine without branching your entire dotfiles repo.
# ~/.zprofile - login shell env
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
export EDITOR='nvim'
export LANG='en_US.UTF-8'
# ~/.zshrc - interactive shell
# Source modular configs
for f in ~/.config/zsh/*.zsh; do
[[ -r "$f" ]] && source "$f"
done
# ~/.config/zsh/aliases.zsh
alias ll='ls -lAhG'
alias g='git'
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
# ~/.config/zsh/prompt.zsh
autoload -Uz vcs_info
precmd() { vcs_info }
zstyle ':vcs_info:git:*' formats '%b'
PROMPT='%F{cyan}%~%f %F{yellow}${vcs_info_msg_0_}%f %# '
Bootstrap Script for a New Machine
The point of tracking dotfiles in Git is that standing up a new machine should take minutes. Write a single bootstrap script that you can curl and run. The script must be idempotent - safe to run multiple times.
The bootstrap sequence on macOS is: install Xcode Command Line Tools, install Homebrew, install Git via Homebrew, clone the bare repo, checkout the files, source the shell config. Xcode CLT is the blocker; it requires user interaction to accept a license on the first install.
One complication: if any of the tracked dotfiles already exist in $HOME from a previous install or macOS defaults, git checkout will refuse to overwrite them. Handle this by backing up conflicts before checking out. The snippet below moves conflicting files to ~/.dotfiles-backup/ and then retries checkout.
For teams that run repeated machine provisioning - imaging lab machines or onboarding new engineers - wrapping this in an automated workflow saves real time. Platforms like taskbotshub.ai let you trigger bootstrap scripts as part of a wider DevOps pipeline, so a new machine can be handed off already configured.
#!/usr/bin/env zsh
# bootstrap.sh - idempotent dotfiles installer for macOS
set -euo pipefail
DOTFILES_REPO="git@github.com:youruser/dotfiles.git"
DOTFILES_DIR="$HOME/.dotfiles"
BACKUP_DIR="$HOME/.dotfiles-backup"
# 1. Xcode CLT
if ! xcode-select -p &>/dev/null; then
echo 'Installing Xcode Command Line Tools...'
xcode-select --install
echo 'Rerun this script after CLT install completes.'
exit 0
fi
# 2. Homebrew
if ! command -v brew &>/dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
brew install git
# 3. Clone bare repo
if [[ ! -d "$DOTFILES_DIR" ]]; then
git clone --bare "$DOTFILES_REPO" "$DOTFILES_DIR"
fi
function dotfiles {
/usr/bin/git --git-dir="$DOTFILES_DIR" --work-tree="$HOME" "$@"
}
# 4. Backup conflicts and checkout
mkdir -p "$BACKUP_DIR"
dotfiles checkout 2>&1 | grep -E "\s+\." | awk '{print $1}' | while read -r f; do
mkdir -p "$BACKUP_DIR/$(dirname "$f")"
mv "$HOME/$f" "$BACKUP_DIR/$f"
done
dotfiles checkout
dotfiles config --local status.showUntrackedFiles no
# 5. Brewfile
if [[ -f "$HOME/Brewfile" ]]; then
brew bundle install --file="$HOME/Brewfile"
fi
echo 'Bootstrap complete. Restart your shell.'
Managing Multi-Machine Divergence with Git Branches
You rarely want identical configs on a work MacBook Pro and a personal Mac Mini. Use Git branches to model machine-specific divergence rather than runtime conditionals scattered through your zshrc.
Create a main branch that holds configs common to all machines. Create per-machine branches - call them work-mbp, home-mini, or whatever is readable to you. Machine-specific files live only on the branch for that machine. Shared updates flow from main via rebase or merge.
This approach keeps your common config clean and makes it obvious what is machine-specific. The tradeoff is branch hygiene: you must remember to rebase feature branches onto main when you update shared configs. Set up a reminder or a weekly cron that checks whether the branch is behind main.
An alternative to branches is a single branch with a machine-detection block in your zshrc. We use hostname-based detection for small divergences like Homebrew prefix differences between Apple Silicon and Intel, but branches for anything more than a few lines.
# Branch strategy
git --git-dir=$HOME/.dotfiles/ branch -a
# main
# work-mbp
# home-mini
# On work MacBook: checkout work-mbp
dotfiles checkout work-mbp
# Merge shared updates from main
dotfiles fetch origin
dotfiles rebase origin/main
# Machine detection fallback for small differences
# In ~/.zshrc:
case "$(hostname -s)" in
MacBook-Pro*)
export HOMEBREW_PREFIX="/opt/homebrew"
;;
Mac-Mini*)
export HOMEBREW_PREFIX="/opt/homebrew"
alias vpn-up='networksetup -connectpppoeservice "Work VPN"'
;;
esac
SSH Config and Secrets Handling
Track ~/.ssh/config in your dotfiles. Do not track private keys, known_hosts, or authorized_keys. The SSH config file is plain text and benefits enormously from version control - you want a record of when you added a bastion host or changed a ProxyJump chain.
For actual secrets - API tokens, signing keys, machine-specific credentials - use a separate mechanism. On macOS, the Keychain is the right store for interactive secrets. For non-interactive scripts, environment variables sourced from a file outside the dotfiles repo work well. Keep a ~/.secrets file that is never committed and source it from ~/.zprofile.
Add ~/.secrets to your global gitignore immediately. One git add -A on a tired evening and you will push credentials to GitHub. The global ignore is your safety net.
For GPG signing, track ~/.gnupg/gpg.conf and ~/.gnupg/gpg-agent.conf. Export public keys and commit them to the repo as a reference. Never commit the private keyring.
# ~/.ssh/config - safe to track
Host bastion-prod
HostName 203.0.113.10
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
AddKeysToAgent yes
UseKeychain yes
Host *.internal
ProxyJump bastion-prod
User admin
StrictHostKeyChecking accept-new
# ~/.secrets - NOT tracked
export GITHUB_TOKEN='ghp_...'
export AWS_PROFILE='prod'
export ANTHROPIC_API_KEY='sk-ant-...'
# Source in ~/.zprofile
[[ -f ~/.secrets ]] && source ~/.secrets
# Add to global gitignore
echo '.secrets' >> ~/.gitignore_global
echo '.env' >> ~/.gitignore_global
# GPG agent config - safe to track
# ~/.gnupg/gpg-agent.conf
default-cache-ttl 600
max-cache-ttl 7200
pinentry-program /opt/homebrew/bin/pinentry-mac
Git Config Worth Tracking
Your ~/.gitconfig is one of the highest-value files to put under version control. It accumulates years of workflow preferences and is painful to recreate from memory.
Split your gitconfig using the includeIf directive to handle work versus personal identities. The main ~/.gitconfig holds global settings. A work-specific file sets the work email and signing key only when you are inside work project directories.
Track your ~/.gitconfig but be careful with credential.helper. On macOS this is set to osxkeychain by default. That is safe to track. What you must not track is a plaintext credential file.
Useful global Git settings that are often overlooked: push.autoSetupRemote yes (added in Git 2.37) removes the need to run git push -u origin main the first time; rerere.enabled yes records conflict resolutions and reapplies them automatically; column.ui auto makes git branch output readable at a glance.
# ~/.gitconfig
[user]
name = Your Name
email = personal@example.com
signingkey = ABCD1234
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
[core]
excludesfile = ~/.gitignore_global
editor = nvim
autocrlf = input
pager = delta
[push]
autoSetupRemote = true
default = current
[pull]
rebase = true
[rerere]
enabled = true
[column]
ui = auto
[alias]
lg = log --oneline --graph --decorate --all
st = status -sb
wip = !git add -A && git commit -m 'wip'
[delta]
navigate = true
line-numbers = true
syntax-theme = gruvbox-dark
# ~/.gitconfig-work
[user]
email = you@company.com
signingkey = WXYZ5678
Keeping Dotfiles Updated Across Sessions
Pulling dotfile updates on a machine you have not used in a while should be a one-command operation. Add a dotfiles pull alias and wire it into a shell hook if you want automatic updates.
We do not recommend auto-pull on shell startup - it adds latency to every new terminal and can break your environment mid-session if a pull brings in breaking changes. Instead, run dotfiles pull manually or set a periodic launchd job that pulls and logs the result without touching the active shell.
On macOS, launchd is the right tool for periodic background tasks, not cron. Create a plist in ~/Library/LaunchAgents that runs every 12 hours. The job runs dotfiles fetch, not pull - fetch is safe; it updates remote tracking refs without changing your working tree. You then decide when to merge.
For teams where dotfiles drift is a real operational problem, a nightly report showing which machines are behind a specific commit is worth building. This is the kind of repetitive sysadmin task that fits naturally into a workflow automation platform.
# Manual update alias in ~/.zshrc
alias dotfiles-update='dotfiles pull && echo "Dotfiles updated. Reload shell: exec zsh"'
# launchd plist for periodic fetch
# ~/Library/LaunchAgents/org.myunix.dotfiles-fetch.plist
cat > ~/Library/LaunchAgents/org.myunix.dotfiles-fetch.plist << 'EOF'
Label
org.myunix.dotfiles-fetch
ProgramArguments
/usr/bin/git
--git-dir=/Users/youruser/.dotfiles
--work-tree=/Users/youruser
fetch
origin
StartInterval
43200
RunAtLoad
EOF
launchctl load ~/Library/LaunchAgents/org.myunix.dotfiles-fetch.plist
Testing Your Setup in a Clean Environment
Before you declare your dotfiles ready, test the bootstrap script in a clean environment. On macOS, the fastest option is a local VM using UTM with an Apple Silicon macOS guest, or an OrbStack VM running Linux if you also want to verify cross-platform compatibility.
Create a fresh macOS user account specifically for bootstrap testing. Switch to that user, open Terminal, and run your bootstrap script cold. Time it. On our test Mac Mini M4 with a 500 Mbps connection, a full bootstrap including Homebrew, 47 Brew packages, and dotfile checkout took 8 minutes 42 seconds. That is a useful baseline to track over time as you add packages.
Another test: deliberately corrupt your test environment by pre-creating some of the files your dotfiles would install. Verify that the backup-and-checkout logic in your bootstrap script handles conflicts correctly and does not silently skip files.
Version your bootstrap script itself. Tag releases - v1.0, v2.0 - so you can point a new machine at a known-good tag rather than HEAD.
# Create test user via CLI
sudo dscl . -create /Users/dottest
sudo dscl . -create /Users/dottest UserShell /bin/zsh
sudo dscl . -create /Users/dottest RealName 'Dotfiles Test'
sudo dscl . -create /Users/dottest UniqueID 503
sudo dscl . -create /Users/dottest PrimaryGroupID 20
sudo dscl . -create /Users/dottest NFSHomeDirectory /Users/dottest
sudo createhomedir -c -u dottest
# Switch to test user
su - dottest
# Run bootstrap against a tagged release
curl -fsSL https://raw.githubusercontent.com/youruser/dotfiles/v2.1/bootstrap.sh | zsh
# Check elapsed time
time zsh bootstrap.sh
# Verify tracked files are in place
dotfiles status
dotfiles log --oneline -5