GNU Stow for dotfiles

2026-02-04 · #dotfiles #stow #linux #devenv

Your dotfiles deserve version control. Track changes. Roll back mistakes. Sync across machines. That's Git.

Getting configs into Git is easy. Getting them back out? That's the hard part.

You could copy files manually. Write a script that creates symlinks. I did that for years. Works until it doesn't. New config? Forget to update the script. New machine? Paths break. Want to disable something temporarily? No clean way.

GNU Stow solves this. It's been around since the early 90s. Makes dotfile management actually pleasant.

This isn't a "clone my dotfiles" post. Most dotfiles repos are opinionated, large, and hard to untangle. I want to explain the pattern so you can build your own setup that makes sense for you.

What Stow actually does

Stow creates symlink farms. That's the technical term. In practice: move your configs into a repo. Organise them into packages. Stow creates the symlinks. Your system still finds configs where it expects.

Before: configs scattered in your home directory:

~/
├── .bashrc
├── .gitconfig
└── .tmux.conf

After: configs in a Git repo, symlinks in home:

~/dotfiles/                    # this is your repo
├── bash/
│   └── .bashrc                # actual file lives here
├── git/
│   └── .gitconfig
└── tmux/
    └── .tmux.conf

~/
├── .bashrc -> dotfiles/bash/.bashrc      # symlink
├── .gitconfig -> dotfiles/git/.gitconfig
└── .tmux.conf -> dotfiles/tmux/.tmux.conf

Each top-level directory in the repo is a "package". That's the key concept.

The package convention

The structure follows a simple pattern:

~/dotfiles/<package>/<path-from-home>

The package name is whatever you want. Call it bash, shell, zsh-config. Doesn't matter. It's just a label for grouping.

Everything inside the package mirrors your home directory exactly. File lives at ~/.bashrc? Put it in <package>/.bashrc. Lives at ~/.config/nvim/init.lua? Put it in <package>/.config/nvim/init.lua.

The package name is how you select what to install. Run stow bash and only the bash package gets symlinked. Run stow bash git nvim and you get all three. Pick and choose.

The commands you'll actually use

Install a package:

cd ~/dotfiles
stow bash

That's it. Stow figures out that bash/.bashrc should become ~/.bashrc.

Remove a package (run from ~/dotfiles):

stow -D bash

The symlink is gone. The file in your dotfiles repo is untouched.

Stow multiple packages:

stow bash git tmux nvim

I run something like this after cloning my dotfiles to a new machine.

You can use stow */ to stow everything at once. The */ is shell globbing—skips hidden directories like .git/. But non-package directories like scripts/? Stow will try anyway. Safer to list packages explicitly.

Nested directories work too

Stow handles nested paths. If your package has multiple files:

git/
├── .gitconfig
└── .gitignore.global

Running stow git creates both symlinks in your home directory.

For configs that live in ~/.config/, remember the rule: the package mirrors the path from home. Your neovim config lives at ~/.config/nvim/init.lua, so the package structure is:

nvim/                      # package name (your choice)
└── .config/               # mirrors ~/
    └── nvim/              # mirrors ~/.config/
        └── init.lua       # the actual file

Looks odd having .config/nvim/ nested inside an nvim/ package. But that's the pattern. Package name is your label. Contents mirror home. After stow nvim:

~/.config/nvim/init.lua -> ../../dotfiles/nvim/.config/nvim/init.lua

Gotcha: You must name it .config with the dot. Stow just mirrors exactly what you have. Easy to forget the dot, or miss the directory when listing (use ls -a).

Stow creates the intermediate directories if they don't exist.

Why packages beat one big config

I've got 29 packages in my dotfiles repo. Some examples:

  • bash - shell config, including modular .bashrc.d/ fragments
  • git - gitconfig, global gitignore
  • tmux - tmux.conf
  • nvim - neovim config
  • mbsync - email sync configuration
  • notmuch - email search and tagging
  • aerc - terminal email client

This matters. Here's why.

Selective installation. My work laptop doesn't need my personal email config. I just don't stow mbsync, notmuch, and aerc on that machine.

Easy testing. Want to try a new shell config? Unstow it, make changes, restow. If something breaks, you know exactly which package caused it.

Clean removal. Experimenting with a new tool? Stow it. Don't like it? stow -D toolname and it's gone. No leftover symlinks.

Conflict detection. Regular file already exists? Stow won't create that link. Tells you what's blocking. Directories are different—Stow merges into them, adding symlinks alongside existing files.

Getting started

Install Stow:

# Debian/Ubuntu
sudo apt install stow

# macOS
brew install stow

# Arch
sudo pacman -S stow

Create your dotfiles repo:

mkdir ~/dotfiles
cd ~/dotfiles
git init

Move your first config (back it up first if you're cautious—tar cvf ~/dotfiles-backup.tar ~/.bashrc):

mkdir bash
mv ~/.bashrc bash/
stow bash

Check the symlink:

ls -la ~/.bashrc
# .bashrc -> dotfiles/bash/.bashrc

The symlink resolves to ~/dotfiles/bash/.bashrc. Stow uses relative paths, so it shows dotfiles/... without the ~/.

Migrating gradually

You don't have to convert everything at once. Migrate one config at a time. Start with something low-risk like Git:

cd ~/dotfiles
mkdir git
mv ~/.gitconfig git/
stow git

Test it. If Git still works, commit and move on. Build up your packages over weeks if you like.

Edits are live

Edit ~/.gitconfig and you're editing the repo file directly. Edit the repo file and the system sees it immediately. One file, two paths.

This is a feature. Run git diff in your dotfiles repo. See exactly what changed. If a tool updates its own config, you'll spot it immediately.

Tips and gotchas

Use dry run to preview. Run stow -n bash to see what Stow would do without actually doing it.

Adopt existing files. File already exists and blocking? stow --adopt bash moves the existing file into your package, then creates the symlink. Useful for initial migration. Run git diff afterward to see what the existing file contained.

Run from inside the dotfiles directory. Stow uses the current directory as the source. The parent becomes the target. From ~/dotfiles, target is ~.

Multiple packages can share directories. Want scripts from different packages to all end up in ~/.local/bin/? Mirror the target path inside each package. Run stow util-scripts claude aerc and Stow merges them.

Some files shouldn't be stowed. Create a .stow-local-ignore file in the package directory. Patterns are Perl regex. Example: ^README\.md$ keeps docs in the repo but out of your home directory.

Token files need special handling. They should be gitignored but still stowed. Add to .gitignore (and git rm --cached if already tracked). On fresh clones, copy from template first.

What Stow doesn't do

Stow is simple. That's its strength. But you'll hit limits:

Secrets. How do you manage API keys across machines without committing them? Stow can't help here.

Machine-specific configs. Your work laptop needs different Git email than your personal machine. Stow has no conditionals.

Templating. What if a config needs your username, a secret, or a machine-specific path baked in? Stow just symlinks. It can't generate files.

Package installation. Stow puts configs in place, but who installs the actual software? On a fresh machine, you need something to bootstrap.

One-command setup. Wouldn't it be nice to clone a repo on a new machine and run one command to get everything?

These are all solvable. I've got answers for each. But that's the next article.

Examples: The code from this article is available as a GitHub gist. Grab it as a starting point.