Dotfiles & Configuration
Your development environment is a tool you have spent years customizing. Shell aliases, editor settings, git config, terminal preferences -- it all accumulates into a setup that makes you fast. Then you get a new laptop, and it is gone. Or you SSH into a server, and you are back to default bash with no aliases and no muscle memory.
Version control your environment. Treat your dotfiles like a project. When you move to a new machine, run one script and be productive in an hour instead of a week.
What to Version Control
Not every file in your home directory belongs in a dotfiles repo. Include the files that represent intentional configuration decisions. Exclude the files that are machine-specific or contain secrets.
Include:
~/.zshrc or ~/.bashrc Shell configuration
~/.gitconfig Git settings and aliases
~/.config/nvim/ or ~/.vimrc Editor configuration
~/.tmux.conf Terminal multiplexer config
~/.ssh/config SSH host aliases (not keys)
~/.config/starship.toml Prompt configuration
~/.config/alacritty/ Terminal emulator settings
~/.tool-versions asdf version manager config
Brewfile macOS package list
Exclude:
~/.ssh/id_* SSH private keys
~/.gnupg/ GPG keys
~/.env or ~/.secrets API keys, tokens
~/.zsh_history Command history
~/.config/*/Cache/ Application caches
The rule of thumb: if you created or edited a file intentionally, it probably belongs in the repo. If an application generated it automatically, it probably does not.
Repository Structure
There are two common approaches to organizing dotfiles: flat and directory-based.
# Flat approach (simple, uses symlinks)
dotfiles/
.zshrc
.gitconfig
.tmux.conf
.vimrc
install.sh
# Directory approach (organized by tool)
dotfiles/
git/
.gitconfig
.gitignore_global
shell/
.zshrc
.zprofile
aliases.zsh
functions.zsh
editor/
init.lua
plugins.lua
terminal/
.tmux.conf
alacritty.yml
install.sh
Brewfile
The directory approach scales better when your configuration grows. Splitting shell config into separate files (aliases, functions, environment) keeps each file focused and easier to maintain.
The Install Script
The install script is the most important file in the repo. It should handle everything: installing packages, creating symlinks, and setting up tools. It should be idempotent -- running it twice should not break anything.
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Install Homebrew (macOS)
if [[ "$OSTYPE" == "darwin"* ]] && ! command -v brew &>/dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Install packages
if [[ "$OSTYPE" == "darwin"* ]]; then
brew bundle --file="$DOTFILES_DIR/Brewfile"
fi
# Create symlinks
link_file() {
local src="$1" dst="$2"
if [ -L "$dst" ]; then
rm "$dst"
elif [ -f "$dst" ]; then
mv "$dst" "${dst}.backup"
echo "Backed up $dst to ${dst}.backup"
fi
ln -s "$src" "$dst"
echo "Linked $src -> $dst"
}
link_file "$DOTFILES_DIR/git/.gitconfig" "$HOME/.gitconfig"
link_file "$DOTFILES_DIR/shell/.zshrc" "$HOME/.zshrc"
link_file "$DOTFILES_DIR/editor/init.lua" "$HOME/.config/nvim/init.lua"
link_file "$DOTFILES_DIR/terminal/.tmux.conf" "$HOME/.tmux.conf"
echo "Dotfiles installed."
Symlinks are better than copies. When you edit ~/.zshrc, you are editing the file in the repo directly. No need to remember to copy it back.
Shell Configuration
Your shell config is where you spend most of your time. A well-organized .zshrc or .bashrc is worth maintaining carefully.
# .zshrc structure
# 1. Environment variables
export EDITOR="nvim"
export LANG="en_US.UTF-8"
export PATH="$HOME/.local/bin:$PATH"
# 2. Tool initialization
eval "$(starship init zsh)"
eval "$(mise activate zsh)" # or asdf, fnm, pyenv
# 3. Aliases
alias g="git"
alias gs="git status"
alias gd="git diff"
alias gc="git commit"
alias gp="git push"
alias gl="git log --oneline --graph -20"
alias k="kubectl"
alias tf="terraform"
alias dc="docker compose"
alias ll="ls -alh"
alias ..="cd .."
alias ...="cd ../.."
# 4. Functions
mkcd() { mkdir -p "$1" && cd "$1"; }
# 5. Source additional files
for file in ~/.config/shell/*.zsh; do
[ -f "$file" ] && source "$file"
done
# 6. Local overrides (not in version control)
[ -f ~/.zshrc.local ] && source ~/.zshrc.local
The local override file (~/.zshrc.local) is important. Machine-specific settings -- like work vs. personal email in git, or paths to locally installed tools -- go here and stay out of version control.
Git Configuration
Git config has two levels: global defaults and per-repo overrides. Your global config should set sensible defaults that work everywhere.
# .gitconfig
[user]
name = Your Name
email = your@email.com
[core]
editor = nvim
autocrlf = input
excludesfile = ~/.gitignore_global
[init]
defaultBranch = main
[pull]
rebase = true
[push]
autoSetupRemote = true
[rerere]
enabled = true
[diff]
algorithm = histogram
colorMoved = default
[merge]
conflictstyle = zdiff3
[alias]
co = checkout
br = branch
st = status
lg = log --oneline --graph --decorate -20
undo = reset --soft HEAD~1
amend = commit --amend --no-edit
wip = !git add -A && git commit -m 'WIP'
cleanup = !git branch --merged main | grep -v main | xargs -r git branch -d
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
The includeIf directive is how you handle multiple identities. All repos under ~/work/ use your work email and signing key. Everything else uses your personal config. No forgetting to set the right email on a new repo.
# .gitconfig-work
[user]
email = you@company.com
signingkey = ABC123
[commit]
gpgsign = true
Global Gitignore
Some files should never be committed in any project. Rather than adding them to every project's .gitignore, set a global ignore file.
# ~/.gitignore_global
# macOS
.DS_Store
._*
# Editors
*.swp
*.swo
*~
.idea/
.vscode/settings.json
*.code-workspace
# Environment
.env.local
.env.*.local
# Dependencies (when not using lockfiles)
node_modules/
# Build artifacts
*.pyc
__pycache__/
Package Management with Brewfile
On macOS, a Brewfile captures every package you have installed via Homebrew. It is a manifest of your development tools.
# Brewfile
# CLI tools
brew "git"
brew "gh"
brew "jq"
brew "ripgrep"
brew "fd"
brew "fzf"
brew "bat"
brew "eza"
brew "tmux"
brew "neovim"
brew "starship"
brew "mise"
brew "httpie"
brew "tldr"
# Languages and runtimes
brew "node"
brew "python"
brew "go"
brew "rust"
# Infrastructure
brew "docker"
brew "kubectl"
brew "terraform"
brew "awscli"
# Applications
cask "alacritty"
cask "rectangle"
cask "1password"
Run brew bundle dump to generate a Brewfile from your current installations. Run brew bundle to install everything from a Brewfile. This takes a new Mac from zero to fully equipped in one command (and about 20 minutes of downloading).
Keeping Dotfiles Updated
The biggest failure mode with dotfiles is forgetting to commit changes. You tweak an alias, it works great, you forget to push it, and it is lost when you reformat your machine.
Strategies:
- Add an alias that commits and pushes dotfiles changes:
alias dotcommit="cd ~/dotfiles && git add -A && git commit -m 'Update' && git push"
- Set a weekly reminder to check for uncommitted changes
- Use a tool like chezmoi that tracks changes automatically
Tools like chezmoi or GNU stow add sophistication to dotfiles management: templating (different values per machine), secret management (encrypted files for API keys), and automatic sync. For most developers, a git repo with symlinks and an install script is sufficient. Use a dedicated tool when you manage dotfiles across multiple machines with different operating systems.
Common Pitfalls
- Not starting a dotfiles repo. The best time to start was when you got your first dev machine. The second best time is now. It does not need to be perfect. Start with
.zshrcand.gitconfig. - Copying files instead of symlinking. If you copy files, you have to remember to copy them back when you change something. Symlinks keep the repo and the live files in sync automatically.
- Committing secrets. SSH keys, API tokens, and passwords do not belong in a dotfiles repo, even a private one. Use the
.localoverride pattern for sensitive values. - Over-engineering the install script. A 500-line install script that handles every edge case is harder to maintain than a 30-line script that handles the common case. Start simple.
- Platform-specific config without conditionals. If you use both macOS and Linux, your install script needs to detect the OS and adjust. Do not assume everyone runs macOS.
- Never updating after initial setup. Dotfiles are a living project. When you add a useful alias or tweak a setting, commit it.
Key Takeaways
- Version control your shell config, git config, editor settings, and tool configuration. When you set up a new machine, run one script and be productive in an hour.
- Use symlinks, not copies. Edits to your live config files automatically update the repo.
- Include an idempotent install script that handles package installation, symlink creation, and tool setup.
- Use a
.localoverride file for machine-specific settings and secrets that should not be in version control. - Use
includeIfin git config to handle multiple identities (work vs. personal). - Keep a Brewfile (or equivalent) to capture your installed tools. One command to install everything.
- Start now with what you have. A minimal dotfiles repo is infinitely better than no dotfiles repo.