For about six years my shell configuration lived in a state I can only describe as geological. Layers. A .bashrc from a job I left in 2013, aliases I no longer understood, a .vimrc that referenced plugins I'd long since deleted, and a sediment of "I'll tidy this later" that never got tidied. Every new laptop meant scp-ing the lot across and hoping. This week I finally sorted it out, and because I will absolutely forget the decisions I made, I'm writing them down.
The actual problem
The problem was never that my config was bad. Bits of it were genuinely good and I'd refined them over years. The problem was that there was no single source of truth. The "good" .gitconfig existed in three slightly different versions across three machines, and I could never remember which one had the alias I wanted. When I broke something, there was no history to roll back to. And onboarding a new machine was a manual, error-prone ritual that I dreaded enough to put off, which meant new machines limped along half-configured for weeks.
So the goal was simple to state: one repository, versioned, that I can clone onto a fresh box and have my environment up in a couple of minutes. Not clever. Just correct.
Layout
I went with the dullest possible structure, because clever directory schemes are the thing you regret. Everything lives under ~/.dotfiles, grouped loosely by tool:
~/.dotfiles
├── bootstrap.sh
├── git/
│ ├── gitconfig
│ └── gitignore_global
├── shell/
│ ├── bashrc
│ ├── aliases
│ └── exports
├── vim/
│ └── vimrc
└── tmux/
└── tmux.conf
Note the missing leading dots in the repo. The files are stored as gitconfig, not .gitconfig, so they aren't hidden when I'm browsing the repo itself. The leading dot gets added at symlink time. Small thing, but it makes the repo much easier to actually read.
Symlinks, not copies
The decision that matters most: the files in $HOME are symlinks pointing back into the repo, not copies. This is the whole trick. If they were copies I'd be back to the synchronisation problem, just with extra steps. With symlinks, editing ~/.vimrc is editing ~/.dotfiles/vim/vimrc, and git status in the repo immediately shows me what I've changed. There's no "did I remember to copy it back" because there's nothing to copy.
The bootstrap script does the linking. I deliberately kept it in plain POSIX shell rather than reaching for one of the dotfile managers like GNU Stow or a Ruby framework. Not because those are bad, several are excellent, but because a fresh machine might not have them, and the entire point was to remove dependencies from the onboarding path. The script needs to run with nothing but a shell and git.
#!/usr/bin/env bash
set -euo pipefail
DOTFILES="$HOME/.dotfiles"
link() {
local src="$DOTFILES/$1"
local dst="$HOME/$2"
if [ -e "$dst" ] && [ ! -L "$dst" ]; then
mv "$dst" "$dst.backup"
echo "backed up existing $dst -> $dst.backup"
fi
ln -sfn "$src" "$dst"
echo "linked $dst"
}
link shell/bashrc .bashrc
link shell/aliases .aliases
link git/gitconfig .gitconfig
link git/gitignore_global .gitignore_global
link vim/vimrc .vimrc
link tmux/tmux.conf .tmux.conf
Two details I care about there. ln -sfn forces the link and treats an existing symlinked directory as a file rather than dropping the link inside it, which is the classic way to end up with ~/.config/nvim/nvim. And the backup step: if there's already a real file (not a symlink) at the destination, I move it aside rather than clobbering it. The first run on an existing machine will quietly stash whatever was there, so I can diff it later and steal anything good before deleting the backup.
Keeping secrets out
The bit everyone gets wrong at least once is committing something they shouldn't. My .gitconfig references work email on work machines and personal email otherwise, and I do not want tokens or per-machine paths in a repo I might one day make public. The pattern I settled on: the committed .bashrc sources a ~/.localrc at the end if it exists, and ~/.localrc is never in the repo.
# bottom of shell/bashrc
[ -f "$HOME/.localrc" ] && source "$HOME/.localrc"
Anything machine-specific or secret lives in ~/.localrc, which stays put on each box. The shared config is genuinely shared; the local overrides stay local. Git config does the same trick with an [include] directive pointing at a .gitconfig.local:
# git/gitconfig (committed)
[user]
name = John Mylchreest
[include]
path = ~/.gitconfig.local
The committed file sets the things that are true everywhere, and the local include layers on whatever this particular machine needs, an email address, a signing key, a corporate proxy. Git merges them at runtime and the secret never touches the repo. I genuinely think this include-a-local-file pattern is the single most important convention in the whole setup, because it's what makes "publish the repo one day" a realistic option rather than a security incident waiting to happen.
The bits I deliberately left out
It's tempting, once you've got the machinery working, to make it do everything. Install packages. Configure the OS. Set up your editor's plugins. I drew a hard line: the dotfiles repo configures programs that are already installed, and nothing more. It does not install software, because the moment it does it stops being portable shell and starts being a half-baked configuration-management tool, and we already have good ones of those.
This restraint matters because the value of the repo is that it runs anywhere with just git and a shell. The instant the bootstrap script needs apt, or brew, or assumes a particular package manager, it stops working on the next exotic box I ssh into and want my aliases on. A jump host. A colleague's machine for ten minutes. A container. Keeping the scope to "link my config files into place" means it works in all of those, and the actual software installation is somebody else's problem, handled by whatever provisions the machine in the first place.
The other thing I left out, on purpose, is cleverness in the shell startup itself. It is very easy to turn a .bashrc into a sprawling framework of functions, prompt themes, and lazy-loaded completions that take a perceptible beat to load every time you open a terminal. I had some of that. I deleted most of it. A shell prompt should appear instantly, and a config file should be something I can read top to bottom and understand. If I want a fancy prompt I'll adopt a dedicated tool for it and keep the rest plain.
Onboarding, end to end
For the record, because the whole exercise was about making this repeatable, here is the entire procedure for a brand-new machine:
git clone https://github.com/jmylchreest/dotfiles.git ~/.dotfiles
cd ~/.dotfiles && ./bootstrap.sh
# then drop in the per-machine secrets
$EDITOR ~/.localrc ~/.gitconfig.local
Three lines, and the last one is optional on a throwaway box. The first time I ran this on a genuinely fresh install and watched my prompt, aliases, vim setup and git config all snap into place, I felt slightly silly about the years I'd spent doing it by hand.
What I'd tell past me
Do it sooner. The job took an afternoon, most of which was the archaeology of deciding which of my four .vimrc variants was the one I actually wanted. The mechanics, the repo and the bootstrap script, were maybe forty minutes. The payoff arrived the very next morning when I cloned it onto my home machine, ran ./bootstrap.sh, and had my prompt, aliases and editor config identical to my work laptop in under a minute.
The deeper lesson is the one I keep relearning in different costumes: configuration that isn't versioned isn't configuration, it's just a pile of files you're emotionally attached to. The moment it lives in git with a one-command setup, it stops being a chore you dread and becomes infrastructure you can trust. I should have done this in 2013, with the .bashrc that started all this. Better six years late than another six.