There is a moment every developer eventually faces: you sit down at a new laptop, open a terminal, and feel the particular grief of a blank slate. Your prompt is wrong. Your aliases are gone. The muscle memory you've built over years — the two-letter shortcuts, the custom Git log format that makes history readable at a glance — all of it has evaporated. You are, for a few humiliating hours, a stranger in your own workflow.
I faced that moment for the fourth time in 2021, after a hard drive replacement that ate my home directory. I had backups of documents and code, but my configuration files — the dotfiles — were scattered across a folder called misc-backup on an old external drive, undated, some of them years out of sync with what I'd actually been using. I spent two days reconstructing things from memory. It was, to borrow a phrase from someone who'd been through it before me, "like trying to rebuild a sandcastle from a photograph."
That week I finally sat down and thought seriously about how I organize my dotfiles. Not just where to put them, but why a given file lives where it does, what the system is trying to protect, and how it should behave on a machine I've never touched before.
The Problem Is Not the Files — It's the Philosophy
Most dotfile guides jump straight to tooling: use GNU Stow, use Chezmoi, use a bare Git repo. The tooling matters, but I've watched people adopt each of those tools and still end up with a chaotic repository six months later, because the tool never forces you to answer the harder question: what is this collection for?
For me, the answer came in three words I wrote on a sticky note and left on my monitor for an embarrassingly long time: portable, legible, opinionated.
Portable means the files should work on any Unix-like system I'm likely to sit at — my personal MacBook, a Linux workstation, an occasional cloud VM — without me manually editing paths or commenting out sections. Legible means that if I open a file six months from now, the structure should explain itself. Opinionated means I'm not building a framework for others; I'm building a system for one person with specific habits, and I should stop pretending otherwise.
That last point took me the longest to accept. I spent years looking at other people's dotfile repositories on GitHub — the ones with thousands of stars and elaborate plugin systems — and feeling like I should be doing something similarly grand. The truth is that most of those repos are either maintained by people with very different workflows, or they've become a hobby unto themselves. A dotfile repository should serve your work, not become it.
The Structure I Actually Use
I keep everything in a single Git repository called dotfiles, sitting at ~/dotfiles. Inside it, I use a directory structure that mirrors the purpose of each config rather than the application name. This sounds like a small distinction, but it changes how you think about the files.
There's a shell/ directory that holds anything touching the interactive shell experience: my .zshrc, a aliases.zsh file, a functions.zsh file, and a path.zsh file that manages $PATH additions in one readable place. The .zshrc itself is deliberately thin — it sources the others in order, and its job is orchestration, not content.
There's a git/ directory with a .gitconfig and a .gitignore_global. The config is heavily commented because Git's options are numerous enough that future-me will not remember why I set rerere.enabled = true without a note explaining what rerere does and why I care about it.
There's a editor/ directory for Neovim configuration, which is its own small universe and probably deserves its own essay. And there's a system/ directory for things that are machine-specific: hostname-based overrides, work-specific environment variables, anything that shouldn't travel blindly to every machine.
That last directory is where the portability problem actually gets solved. At the bottom of my .zshrc, I have a single line:
[ -f ~/.local.zsh ] && source ~/.local.zsh
On each machine, ~/.local.zsh is a file I create manually — it's not tracked in the repository. It holds anything that's specific to that environment: API keys, proxy settings, a $PATH addition for software that only exists on that machine. The repository stays clean; the machine-specific noise lives outside it.
Symlinks, and Why I Stopped Fighting Them
For years I used a custom shell script to deploy my dotfiles — a bootstrap.sh that created symlinks from ~/dotfiles/shell/.zshrc to ~/.zshrc, and so on. I was proud of that script. It was also, I eventually admitted, a maintenance burden I didn't need.
I switched to GNU Stow, which does one thing: it manages symlinks based on directory structure. You tell it to stow the shell package, and it creates symlinks in your home directory that point back into ~/dotfiles/shell/. That's it. No magic, no database, nothing to debug at 11 p.m. when you're setting up a new machine and your brain is already tired.
The simplicity is the point. When I stopped trying to be clever about the deployment mechanism, I had more energy to think about the actual content of the files.
There's a counterargument worth taking seriously: tools like Chezmoi offer templating, so you can write a single config file with conditionals that handle macOS and Linux differences in place. I understand the appeal. But I've found that the cases where I genuinely need platform-specific behavior in a config file are rarer than I expected, and when they do arise, a small shell conditional inside the file is usually cleaner than a template syntax I have to re-learn every time I touch it.
What Version Control Actually Gives You
The Git history of a dotfile repository is, if you maintain it honestly, a kind of autobiography of your working habits. I can look back at commits from 2019 and see the exact moment I stopped using ll as an alias and started using eza instead. I can see when I added a particular Git hook, and the commit message usually tells me what problem prompted it.
This is the underrated argument for how I organize my dotfiles: the repository is not just a backup mechanism. It's a record. When something breaks — when an update to a tool changes a config format, when a new job requires different defaults — I can diff the current state against six months ago and understand what changed and why.
Commit messages in a dotfile repo are worth writing carefully. "Update zshrc" tells you nothing. "Switch from nvm to fnm for faster shell startup" tells you everything you'll need when you're wondering, two years later, why fnm is in your path and nvm is not.
The Thing I Still Haven't Solved
I want to be honest about the limits of my system, because I've read too many essays that end with a tidy bow.
Secrets management is still awkward. I use ~/.local.zsh to keep API keys out of the repository, but that means they're not backed up in any systematic way. I've started moving toward a password manager's CLI for the most critical values, but the integration is imperfect and I haven't found an approach that feels as natural as I'd like.
And there's a deeper question I keep circling: how much of what I organize my dotfiles around reflects habits that are actually good, versus habits I've simply had long enough that they feel good? Some of my aliases are shortcuts to workflows that better tools have since replaced. I keep them out of familiarity, which is not a great reason.
Maybe that's the real argument for keeping the system legible and the history honest. Not so that you can reconstruct your sandcastle perfectly, but so that you can look at what you built and decide, with clear eyes, which parts were worth keeping in the first place.