Reorganizing my dotfiles
With the latest additions to my homelab I needed to reorganize my dotfiles and improve the process of bootstrapping a new machine with the required tools and configuration.
The challenge
Managing dotfiles across multiple machines is straightforward until the machines start diverging. My setup spans a personal MacBook, a work MacBook, and a Linux machine. They share a lot of configuration but differ in identity (email, signing keys), OS specifics, and machine-specific tooling. I wanted one repository that handles all of them without a mess of conditionals scattered through every config file.
The structure
The solution is a four-layer directory hierarchy managed by GNU Stow. Each layer is applied on top of the previous one:
00base/ # applied to every machine
01os/<os>/ # macos or linux
02identity/<id>/ # personal or work
03hosts/<host>/ # machine-specific overrides
Within each layer, packages are plain directories named after the tool they configure. A package contains the files in the same relative path they should end up at in $HOME. For example, 00base/eza/.config/eza/ maps to ~/.config/eza/. Stow creates symlinks, so the actual files always live in the dotfiles repository.
The .stowrc file at the root sets the target and a few ignore patterns:
--target="~"
--ignore='.DS_Store'
--ignore='\.gitkeep$'
--no-folding
--no-folding is important: without it Stow may symlink an entire directory rather than the files inside it, which breaks the layering because a later layer cannot add files inside a directory that is itself a symlink.
The install script
The install script at the root of the repository handles bootstrapping. It auto-detects the hostname and OS, maps them to an identity, and stows all four layers in order:
stow_all_in "00base"
stow_all_in "01os/$OS_GROUP"
stow_all_in "02identity/$IDENTITY"
stow_all_in "03hosts/$HOST_KEY"
The host-to-identity mapping is a simple associative array at the top of the script:
declare -A HOST_TO_IDENTITY
HOST_TO_IDENTITY=(
[work-mbp]=work
[mbp-dvk]=personal
[arch-mbp]=personal
)
Hostname normalization converts the raw hostname -s output to lowercase with hyphens, making it case-insensitive and consistent across platforms.
Before stowing a package the script unstows it first (stow -D). This ensures stale symlinks from a previous run are cleaned up before new ones are created. Any package directory prefixed with _ is skipped entirely, which is useful for work-in-progress packages that should not be deployed yet.
The script also supports a few useful flags:
./install --dry-run # preview without making changes
./install -p "01os/macos/bin" # stow a single package manually
./install -H mbp-dvk # override the detected hostname
Adding a new machine
To add a new machine you add a mapping in the HOST_TO_IDENTITY array, create a 03hosts/<hostname>/ directory with any machine-specific packages, and run ./install. If the machine uses a new OS or identity that does not yet have a layer directory, those are created too. Machines that share all configuration with an existing host can skip the host layer entirely and rely only on the base, OS, and identity layers.
Cleaning up
I also added a clean-env script which removes all symlinks by running stow -D against every known package across all layers. It covers all currently known hosts so it handles cleanup on any of the registered machines.
This structure keeps the repository tidy: every file has an obvious home, the layers compose predictably, and bootstrapping a new machine is a single command.
Expanding the concept
Since the concept worked quite well I applied the same pattern inside individual tool configurations, most notably zsh.
The three main zsh entry points (.zshenv, .zprofile, .zshrc) each contain nothing but a sourcing loop:
setopt null_glob
for conf in "$HOME/.zshrc.d/"*.zsh; do
source "${conf}"
done
unsetopt null_glob
unset conf
.zshenv sources everything in ~/.zshenv.d/, .zprofile sources ~/.zprofile.d/, and .zshrc sources ~/.zshrc.d/. The null_glob option prevents an error when a directory is empty or does not exist. Each file in those directories is a standalone numbered snippet, which means different stow layers can drop files into the same directory and they compose at shell startup without any of them knowing about the others.
The base layer (00base/zsh/) provides the numbered files that cover the universal configuration:
.zshenv.d/00-path.zsh # PATH setup
.zshenv.d/01-tools.zsh # tool environment variables
.zshenv.d/02-editor.zsh # EDITOR / VISUAL
.zprofile.d/01-tools.zsh # login-time tool init (homebrew, etc.)
.zshrc.d/00-zinit.zsh # plugin manager and plugins
.zshrc.d/02-theme.zsh # prompt theme
.zshrc.d/03-tools.zsh # interactive tool init (fzf, zoxide, etc.)
.zshrc.d/04-completions.zsh # completion setup
.zshrc.d/05-functions.zsh # autoloaded functions
.zshrc.d/06-history.zsh # history settings
.zshrc.d/07-shell-options.zsh
.zshrc.d/10-alias.zsh # universal aliases
.zshrc.d/11-keybindings.zsh # key bindings
.zshrc.d/12-prompt.zsh # prompt init
The numeric prefix controls load order. Low numbers run first, leaving the high end of the namespace for identity and host overlays.
The work identity (02identity/work/zsh/) stows a single file into that same directory:
.zshrc.d/99-work.zsh
That file is sourced last and adds everything that only makes sense on a work machine. Because it runs at number 99 it can safely override or extend anything set earlier.
The personal host overlay (03hosts/mbp-dvk/zsh/) follows the same pattern with its own 99-personal.zsh.
Adding a new work-only shell feature means creating or editing a file in 02identity/work/zsh/.zshrc.d/. Adding something machine-specific goes into 03hosts/<hostname>/zsh/.zshrc.d/. Neither touches the base configuration, and the ordering guarantees the base is always established before any overlay runs.