5 min read
On this page

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 .zshrc and .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 .local override 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 .local override file for machine-specific settings and secrets that should not be in version control.
  • Use includeIf in 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.