Dotfiles
Manage and sync configuration files across machines.
Management Strategy Comparison
| Tool | Complexity | Symlinks | Templates | Encryption | Multi-machine |
|---|---|---|---|---|---|
| Git bare repo | Low | No (actual files) | No | No | Manual |
| GNU Stow | Low | Yes | No | No | Manual |
| chezmoi | Medium | No (copies) | Yes | Yes | Built-in |
| yadm | Medium | No (actual files) | Yes (Jinja) | Yes (GPG) | Built-in |
Git Bare Repo (Minimal)
No dependencies, no symlinks. Home directory is the work tree.
git init --bare $HOME/.dotfiles
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dot config --local status.showUntrackedFiles no
echo "alias dot='git --git-dir=\$HOME/.dotfiles --work-tree=\$HOME'" >> ~/.zshrc
dot add ~/.zshrc ~/.gitconfig
dot commit -m "Add shell and git config"
dot remote add origin git@github.com:user/dotfiles.git
dot push -u origin main
# Clone to new machine
git clone --bare git@github.com:user/dotfiles.git $HOME/.dotfiles
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dot checkout
# If conflicts: back up existing files, then checkout again
dot checkout 2>&1 | grep -E "^\s+" | xargs -I{} mv {} {}.bak
dot checkout
dot config --local status.showUntrackedFiles no
GNU Stow (Symlink Manager)
brew install stow # macOS
apt install stow # Debian/Ubuntu
mkdir -p ~/dotfiles && cd ~/dotfiles && git init
Each top-level folder is a "package" mirroring home directory structure:
~/dotfiles/
├── zsh/
│ └── .zshrc
├── git/
│ └── .gitconfig
├── nvim/
│ └── .config/nvim/
│ └── init.lua
├── tmux/
│ └── .tmux.conf
└── scripts/
└── .local/bin/
└── backup.sh
Usage
cd ~/dotfiles
stow zsh # symlink: ~/.zshrc -> ~/dotfiles/zsh/.zshrc
stow */ # stow all packages
stow -D zsh # unstow (remove symlinks)
stow -R zsh # restow (unstow then stow — use after restructuring)
stow -t /etc/nginx nginx # target a different directory
stow -n -v zsh # dry run with verbose output
Conflict Resolution and Ignore
# If target exists, stow refuses. Back up first:
mv ~/.zshrc ~/.zshrc.bak && stow zsh
# Or adopt existing files into stow dir (overwrites stow version):
stow --adopt zsh # then review with git diff
Create .stow-local-ignore in any package to exclude files: README.md, LICENSE, \.git.
chezmoi (Full-Featured)
brew install chezmoi
chezmoi init # new setup
chezmoi init --apply https://github.com/user/dotfiles.git # clone + apply
chezmoi add ~/.zshrc # track a file
chezmoi add --autotemplate ~/.gitconfig # auto-detect template variables
chezmoi add --encrypt ~/.ssh/id_ed25519 # add encrypted
chezmoi edit ~/.zshrc # edit source, not target
chezmoi diff # preview changes
chezmoi apply # apply to home directory
chezmoi update # pull from remote and apply
Templates
# ~/.local/share/chezmoi/dot_gitconfig.tmpl
[user]
name = {{ .name }}
email = {{ .email }}
{{ if eq .chezmoi.os "darwin" }}
[credential]
helper = osxkeychain
{{ end }}
{{ if eq .chezmoi.hostname "work-laptop" }}
[http]
proxy = http://proxy.corp:8080
{{ end }}
# ~/.config/chezmoi/chezmoi.toml
[data]
name = "Jane Doe"
email = "jane@work.com"
Encrypted Files
# ~/.config/chezmoi/chezmoi.toml
encryption = "age"
[age]
identity = "~/.config/chezmoi/key.txt"
recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
Run Scripts
# ~/.local/share/chezmoi/run_onchange_install-packages.sh.tmpl
#!/bin/bash
{{ if eq .chezmoi.os "darwin" -}}
brew bundle --file=/dev/stdin <<EOF
brew "ripgrep"
brew "fd"
brew "fzf"
EOF
{{ else if eq .chezmoi.os "linux" -}}
sudo apt install -y ripgrep fd-find fzf
{{ end -}}
Shell Startup File Order
Bash
Login shell: /etc/profile -> ~/.bash_profile OR ~/.bash_login OR ~/.profile (first found)
Interactive shell: ~/.bashrc
Best practice — put everything in .bashrc, source from .bash_profile:
# ~/.bash_profile
[[ -f ~/.bashrc ]] && source ~/.bashrc
Zsh
Order: .zshenv (always) -> .zprofile (login) -> .zshrc (interactive) -> .zlogin (login)
Logout: .zlogout
Keep .zshenv minimal. Put most config in .zshrc.
Common Shell Config
# -- History --
HISTFILE=~/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE
# -- PATH (deduplicated in zsh) --
typeset -U PATH path
path=("$HOME/.local/bin" "$HOME/go/bin" "$HOME/.cargo/bin" "/opt/homebrew/bin" $path)
export PATH
# -- Aliases --
alias ll='ls -lAh'
alias g='git'
alias dc='docker compose'
alias k='kubectl'
alias ..='cd ..'
alias ...='cd ../..'
# -- Functions --
mkcd() { mkdir -p "$1" && cd "$1"; }
# -- Environment --
export EDITOR='nvim'
export VISUAL='nvim'
eval "$(starship init zsh)"
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
Git Configuration
# ~/.gitconfig
[user]
name = Your Name
email = you@example.com
signingkey = ~/.ssh/id_ed25519.pub
[alias]
co = checkout
br = branch
ci = commit
st = status
sw = switch
lg = log --oneline --graph --all --decorate
unstage = reset HEAD --
amend = commit --amend --no-edit
wip = !git add -A && git commit -m 'WIP'
[core]
editor = nvim
pager = delta
autocrlf = input
excludesFile = ~/.gitignore_global
[init]
defaultBranch = main
[pull]
rebase = true
[push]
autoSetupRemote = true
[merge]
conflictstyle = zdiff3
[diff]
algorithm = histogram
colorMoved = default
[delta]
navigate = true
side-by-side = true
line-numbers = true
[gpg]
format = ssh
[commit]
gpgsign = true
[rerere]
enabled = true
# ~/.gitignore_global
.DS_Store
Thumbs.db
*.swp
*~
.idea/
.vscode/
Terminal Configs
tmux
# ~/.tmux.conf
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
set -g history-limit 50000
set -g escape-time 0
unbind C-b
set -g prefix C-a
bind C-a send-prefix
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
bind r source-file ~/.tmux.conf \; display "Reloaded"
WezTerm
-- ~/.config/wezterm/wezterm.lua
local wezterm = require("wezterm")
local config = wezterm.config_builder()
config.font = wezterm.font("JetBrains Mono")
config.font_size = 14.0
config.color_scheme = "Catppuccin Mocha"
config.hide_tab_bar_if_only_one_tab = true
return config
macOS-Specific
Brewfile
# ~/dotfiles/Brewfile
brew "git"
brew "neovim"
brew "ripgrep"
brew "fd"
brew "fzf"
brew "stow"
brew "starship"
brew "git-delta"
brew "jq"
brew "bat"
brew "eza"
brew "tmux"
cask "wezterm"
cask "raycast"
cask "1password"
cask "docker"
brew bundle --file=~/dotfiles/Brewfile # install
brew bundle dump --force --file=~/dotfiles/Brewfile # export current
macOS Defaults
#!/bin/bash
# ~/.macos — run once on fresh install, then reboot
defaults write com.apple.finder AppleShowAllFiles -bool true
defaults write com.apple.finder ShowPathbar -bool true
defaults write com.apple.dock autohide -bool true
defaults write com.apple.dock autohide-delay -float 0
defaults write NSGlobalDomain KeyRepeat -int 2
defaults write NSGlobalDomain InitialKeyRepeat -int 15
mkdir -p ~/Screenshots
defaults write com.apple.screencapture location ~/Screenshots
killall Finder Dock SystemUIServer 2>/dev/null
Linux-Specific
# ~/.Xresources — apply with: xrdb -merge ~/.Xresources
Xft.dpi: 144
*.foreground: #c0caf5
*.background: #1a1b26
systemd User Services
# ~/.config/systemd/user/ssh-agent.service
[Unit]
Description=SSH Agent
[Service]
Type=simple
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket
[Install]
WantedBy=default.target
systemctl --user enable --now ssh-agent
XDG Base Directory Specification
export XDG_CONFIG_HOME="$HOME/.config" # config files
export XDG_DATA_HOME="$HOME/.local/share" # persistent data
export XDG_CACHE_HOME="$HOME/.cache" # disposable cache
export XDG_STATE_HOME="$HOME/.local/state" # logs, history
Tools that respect XDG: nvim, tmux (3.1+), git, starship, wezterm, alacritty, kitty, bat, ripgrep.
Secrets Management
Never commit secrets in plaintext.
# git-crypt — encrypt specific files in a git repo
brew install git-crypt
cd ~/dotfiles && git-crypt init
# .gitattributes: secrets/** filter=git-crypt diff=git-crypt
git-crypt export-key ~/dotfiles-key # back up this key
git-crypt unlock ~/dotfiles-key # on new machine
# 1Password CLI — in chezmoi templates or shell
# {{ onepasswordRead "op://Personal/SSH Key/private key" }}
export GITHUB_TOKEN=$(op read "op://Dev/GitHub/token")
# age encryption — used natively by chezmoi
age-keygen -o ~/.config/chezmoi/key.txt
# Then set encryption = "age" in chezmoi.toml (see chezmoi section)
Bootstrap Script
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_REPO="git@github.com:user/dotfiles.git"
DOTFILES_DIR="$HOME/dotfiles"
if [[ "$OSTYPE" == darwin* ]]; then
command -v brew >/dev/null || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
eval "$(/opt/homebrew/bin/brew shellenv)"
brew install git stow
elif [[ -f /etc/debian_version ]]; then
sudo apt update && sudo apt install -y git stow
elif [[ -f /etc/arch-release ]]; then
sudo pacman -Sy --noconfirm git stow
fi
[[ ! -d "$DOTFILES_DIR" ]] && git clone "$DOTFILES_REPO" "$DOTFILES_DIR"
cd "$DOTFILES_DIR"
for dir in */; do stow -v "$dir"; done
if [[ "$OSTYPE" == darwin* ]]; then
brew bundle --file="$DOTFILES_DIR/Brewfile" || true
[[ -f "$DOTFILES_DIR/.macos" ]] && bash "$DOTFILES_DIR/.macos"
fi
echo "Bootstrap complete. Restart your shell."
Best Practices
- Version control everything — git repo, push to private GitHub/GitLab
- Modular structure — one stow package or chezmoi directory per application
- No secrets in plaintext — use chezmoi encryption, git-crypt, or 1Password CLI
- Bootstrap script — automate fresh machine setup end to end
- Test before applying — use
chezmoi difforstow -n -vbefore committing - Separate public and private — secrets in private repo, everything else public
- XDG compliance — prefer
~/.config/over~/when tools support it - Idempotent scripts — bootstrap should be safe to run multiple times